import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import {
  Permission,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
  type SheetSnapshot,
  type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { InvocationRecorderService } from '../ai/core/invocation-recorder.service';
import { estimateCostYuan } from '../ai/core/cost';
import { buildSystemPrompt, ASSISTANT_PROMPT_VERSION } from './assistant-prompts';
import {
  slimInputSnapshot,
  briefToolArgs,
  truncateOutputText,
  joinStepTexts,
  stepShapes,
  assistantCallKey,
  assistantInputHash,
  type ToolTraceEntry,
} from './assistant-invocation';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { PlanRearrangeService } from '../plan/plan-rearrange.service';
import { AgentRosterService } from '../plan/agent-roster.service';
import { rearrangeFacts } from '../plan/rearrange-facts';
// ⭐ 给模型的事实投影（⛔ 不再给成品句子，见该文件顶部注释）
import { modelFacts, sheetSnapshotFacts } from '../plan/assignment-facts';
// 引导节点的中文标识 + 取值域（单一真源，沿革见那边注释）
import { GUIDANCE_KIND, GUIDANCE_KINDS } from '../plan/assignment-signals';
// ⭐ 次要业务线的做法:索引常驻(工具的取值域)、正文按需取(见该文件顶部三种加载方式)
import { PLAYBOOKS, PLAYBOOK_INDEX, PLAYBOOK_TOPICS } from './assistant-playbooks';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { AppConfig } from '../../config/configuration';

// ⚠️ 提示词文本**只住在 `assistant-prompts.ts` 一个文件**，按层分块、在那边装配 ——
//   这样"最终喂给模型的是什么"能一眼数清，源码级的提示词回归测试也只需要读一处。
//   这里只负责把装配好的那份和**渠道**追加拼起来。

/**
 * 单轮里最多跑几步工具循环。
 * ⚠️ 改这个值时记得：到顶不是"正常结束"，控制器要把它当异常路径报给前端（no silent cap）。
 *
 * 🔴 2026-08-13 由 8 提到 16 —— **8 不够了，实测撞顶**。
 *   出一版确认单现在的正常步数是：
 *     get_current_user(1) + propose_assignment(1) + show_sheet(1)
 *     + show_guidance × N（还能再筛一刀 1 条 + 待分配 1 条 + 本批多大 1 条 = 3）= 6 步，
 *   主管再追问一句（get_cohort_attributes → 重出一版）就直接顶穿。
 * ⚠️ `place_*` 是**纯位置标记**，不查库不写库，一步几十毫秒 —— 它们占的是步数不是时间。
 *   ⛔ 别为了省步数把它们合并成一个批量调用：那样模型就没法"写一句、摆一个"，
 *   位置又回到前端去猜（那条路已经删过一次）。
 */
export const MAX_TOOL_STEPS = 16;

/** 桌宠"小牙"的人设（pet-say 专用，无工具、极短输出）。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察，用第一人称说一句话：中文，不超过 30 个字，口语化、俏皮但不油腻，最多一个 emoji。
只评论观察里出现的事，不编造患者信息，不给医疗建议，不自称 AI 或模型；不假设用户的性别/称呼（不要叫哥哥姐姐，直接用"你"或不称呼）。
直接输出台词本身，不要引号、不要解释。`;

export interface AssistantChatInput {
  userToken: string;
  modelId?: string;
  messages: ModelMessage[];
  /**
   * **渠道**追加（企微：纯文字、链接格式、多轮指代）—— 拼在整份提示词最后。
   * ⚠️ 2026-08-14 由 `systemExtra` 改名:它此前同时承担"角色层"和"渠道规则"两件事,
   *   于是企微那条路(只传渠道规则)**整份提示词里一层角色都没有**。
   *   现在角色层由 `permissions` 决定、在服务端装配,这里只剩渠道自己的事。
   */
  channelExtra?: string;
  /** 换掉提示词的人设语气那一层；空 = 用默认。见 assistant-prompts 的 VOICE_DEFAULT */
  voice?: string | null;
  /**
   * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
   * (真正的判定在 MCP server 的条件注册与各 handler 里)。
   * 不传 = 按无特权处理：宁可少给工具，也不能把主管的工具漏给客服。
   */
  permissions?: readonly string[];
  /**
   * 侧信道 —— 把**肥载荷**直接推给前端，不经过模型上下文。
   *
   * ⭐ 确认单是这条通道存在的理由：它含几百个 planId(一批 850 人 ≈ 12k token),
   * 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
   * **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
   * ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
   * 同样是它在编。真数据只能由服务端产、由服务端推。
   */
  onSideEvent?: (event: Record<string, unknown>) => void;
  /**
   * 主管当前看的诊所 —— `propose_assignment` 不传 clinicId 时用它兜底。
   * ⚠️ 前端可改的入参，落到 `resolveClinicId` 上仍要过越权闸，⛔ 不因为"是我们自己发的"就信。
   */
  activeClinicId?: string;
  /**
   * 眼前那张**还没确认**的确认单，此刻的样子（由浏览器捎来 —— 服务端查不到，
   * 见 `SheetSnapshotSchema`）。⚠️ 每次请求现取现发，⛔ 这里不许缓存。
   */
  sheetState?: SheetSnapshot;
  /**
   * 详情页当前开着的那位患者 —— **只作指代的默认落点**，⛔ 不注入工具参数
   * （注入等于堵死"查另一位"，而他随时会问别人）。
   */
  activePatientId?: string;
  /** 当前登录人（本地工具要用 scope 取数）;缺省则本地写工具不注册 */
  /**
   * 这一串对话的 id —— 落进 `agent_invocations.workflow_run_id`，
   * 同一次对话的每一轮共用一个值。⛔ 缺失时服务端现生成（那一轮自成一组）。
   */
  conversationId?: string;
  scope?: TenantScopeContext;
  abortSignal?: AbortSignal;
}

/**
 * 哪些工具的 `clinicId` 意思是「**问的是哪一家**」—— 只有这几个由服务端注入。
 *
 * 🔴 ⛔ **别改成"凡是有 clinicId 的都注入"** —— 同一个名字在两类工具里意思不同：
 *   · `get_agents` / `get_cohort_attributes`：不传 = 回落第一家，**必然是某一家**；
 *   · `list_recall_queue` / `recall_queue_stats`：不传 = **不按诊所过滤**（整个权限范围）。
 *   往后者注入等于凭空加一道筛子：客服问"今天该联系谁"会少掉别家的单，
 *   而少掉的部分**没有任何提示**。
 * ⚠️ 这里写死名字是第二处知道工具名的地方 —— 改名会让注入**静默失效**。
 *   `tests/mcp-clinic-scope.spec.ts` 锁住了这两个名字与 CLINIC_ID_SCHEMA 的绑定。
 */
const CLINIC_SCOPED_TOOLS = new Set(['get_agents', 'get_cohort_attributes']);

/**
 * 工具入参里有没有 `clinicId` 这一格。
 * ⛔ 别改成读 `required`：它是**可选**参数，模型照样能填。
 */
function hasClinicIdSlot(schema: unknown): boolean {
  const props = (schema as { properties?: Record<string, unknown> } | null)?.properties;
  return !!props && 'clinicId' in props;
}

/**
 * ⭐ **把 `clinicId` 这一格从模型眼前摘掉** —— 产品内的助手看不到它，由服务端补。
 *
 * 🔴 模型**没有一种填法是对的**：`get_current_user` 回的 `clinicIds` 是一串
 *   **没有名字的 uuid**，「朝阳公园店」对应哪一个它无从得知，只能在几家里挑一个。
 *   这和 2026-08-14 删掉 `propose_assignment.clinicId` 是同一个判断
 *   （「既然会传错为什么不删掉，不让模型知道就行了」）。
 * 🔴 更硬的一条：`propose_assignment` 用的是**主管正看着那家**（`activeClinicId`），
 *   而 MCP 侧不传就回落 `scope.clinicIds[0]`。多诊所主管在第二家的矩阵上问
 *   「这批人构成」，看构成用的是第一家、出方案用的是第二家 —— **两个数对不上，且不报错**。
 *   ⇒ 这里统一注入同一个 `activeClinicId`，两条路径才可能说同一件事。
 *
 * ⚠️ 只对**产品内助手**这条路生效：MCP server 的 schema 一个字没动，
 *   外部 agent（自己知道诊所 id 的那种）照旧能显式指定。
 * ⚠️ 注入的值仍要过越权闸（服务端的 `resolveClinicId`）——
 *   它源自前端入参，⛔ 不因为"是我们自己发的"就当可信。
 * ⛔ 别改成就地删 `schema.properties`：工具清单是**跨请求缓存**的（见 McpClientService），
 *   改原对象等于把后来每个人的清单也改了。
 */
function stripClinicId<T>(schema: T): T {
  if (!hasClinicIdSlot(schema)) return schema;
  const s = schema as { properties: Record<string, unknown>; required?: string[] };
  const { clinicId: _dropped, ...rest } = s.properties;
  return {
    ...s,
    properties: rest,
    ...(s.required ? { required: s.required.filter((k) => k !== 'clinicId') } : {}),
  } as T;
}

/**
 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
 *
 * ⚠️ 凡是**会改变工具清单**的权限，都必须列进来，否则两类人会共用一份缓存（串号且不报错）。
 * 现在只有 `plan:dispatch` 影响清单（主管多出分配/跟踪工具）;将来再有条件注册的工具,
 * 加权限的同时**必须**在这里加一位，并在 mcp-server.factory 的条件注册处对齐。
 */
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
  return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
}

/**
 * AssistantService — 独立的"外部 agent"模拟器（不复用 AiCall 单发框架）。
 *
 * 模型自主决定调哪些工具（model-driven tool-calling,非强制工作流）：
 *   动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环
 *   (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。
 * provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。
 */
@Injectable()
export class AssistantService {
  private readonly logger = new Logger(AssistantService.name);

  constructor(
    private readonly provider: AiProviderService,
    private readonly mcp: McpClientService,
    private readonly proposals: AssignmentProposalService,
    // 调整是与分配**并列**的另一条路(不建批次、没有福利),不塞进 proposals
    private readonly rearrange: PlanRearrangeService,
    /// 姓名 -> userId:模型手上只有客服姓名,而服务端按 id 取数
    private readonly roster: AgentRosterService,
    private readonly config: ConfigService<AppConfig, true>,
    private readonly recorder: InvocationRecorderService,
  ) {}

  // 显式收窄返回类型为控制器实际消费的最小形状，避开 streamText 返回类型引用 ai 内部
  // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
  async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
    // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用，带用户 token)
    const mcpTools = await this.mcp.listTools(
      input.userToken,
      mcpCapabilityKey(input.permissions),
    );
    const tools: ToolSet = {};
    for (const t of mcpTools) {
      /// ⭐ 模型看不到 clinicId(见 stripClinicId);服务端在 execute 里补他正看着那家
      const clinicScoped = CLINIC_SCOPED_TOOLS.has(t.name) && hasClinicIdSlot(t.inputSchema);
      const injectClinic = clinicScoped && !!input.activeClinicId;
      tools[t.name] = tool({
        description: t.description ?? t.name,
        inputSchema: jsonSchema(clinicScoped ? stripClinicId(t.inputSchema) : t.inputSchema),
        execute: async (rawArgs: unknown) => {
          const args = injectClinic
            ? { ...((rawArgs ?? {}) as Record<string, unknown>), clinicId: input.activeClinicId }
            : rawArgs;
          // ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
          //   「模型没调工具，却报了一串看起来很像真的数字」（2026-08-06 实测：
          //   它对主管说「这批共 70 人，重要价值 4 人、青少年 3 人」，而
          //   get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40）。
          //   没有这行日志，排查时根本分不清「工具算错了」还是「压根没算」。
          // ⚠️ args 截断 200 字：确认单那类肥载荷不该灌进日志。
          const brief = JSON.stringify(args ?? {}).slice(0, 200);
          this.logger.log(`工具调用：${t.name} args=${brief}`);
          try {
            return await this.mcp.callTool(input.userToken, t.name, args);
          } catch (e) {
            // ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编,
            //   而那正是最难发现的失败形态。
            this.logger.warn(`工具失败：${t.name} ${e instanceof Error ? e.message : e}`);
            throw e;
          }
        },
      });
    }

    // ── 本地工具：全景确认单 ─────────────────────────────────
    // ⚠️ 刻意**不做成 MCP 工具**：MCP 的返回值会原样回灌进模型上下文,
    //   而这个载荷有几百个 planId。这里走侧信道推前端，只回给模型一句短话。
    if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
      const scope = input.scope;
      const push = input.onSideEvent;
      /**
       * 本轮最近一次确认单里「要主管定的」有哪几类 —— 给 show_guidance 校验用。
       *
       * 两个数组**同序**：`guidanceKinds[i]` 是给模型看的中文标识，
       * `guidanceKeys[i]` 是给界面用的内部 key。⛔ 别只留一个：
       *   前者进模型上下文，后者只走侧信道，两边**永远不能混**。
       * ⚠️ 只在**同一轮**有效（tools 是每次 chat() 现建的）。跨轮时为空，
       *   那时不拦，交给界面按自己手里那份确认单匹配（它才是权威）。
       */
      let guidanceKinds: string[] = [];
      let guidanceKeys: string[] = [];
      /// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
      let sheetPushed = false;
      tools.propose_assignment = tool({
        /**
         * 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来：
         *   「并把卡片呈现给主管」「卡片由界面渲染」→ **界面词**，而且是错的：
         *      呈现时机由 show_sheet 决定，不是这个工具。
         *   「返回的是结构化事实…组织成人话」→ 系统提示词那一节整段在管。
         *   「绝不要说已经分配好了」→ 提示词权责段 + 返回值键名 `已经分下去了`，这是第三份。
         *   「专属客服排满的不会自动改派」→ 返回值里 `专属客服排满、这批没发的` 自己说清了（判据②）。
         *   「人数与时限不问主管」→ 提示词流程线里的「直出，不追问」，复述。
         *
         * ⚠️ 末尾那段**讲述顺序**放在这里而不是提示词里：耦合有方向 ——
         *   生产者知道自己产出什么、该被怎么读；show_* 是呈现器，⛔ 它们不该知道内容结构。
         *   ⚠️ 里面**不点名那两个工具**，用的是它们自己描述里的动词（"看明细""开放"）。
         *
         * 🔴 **2026-08-15：那段顺序里的第一步"选人"一度整个丢了。**
         *   上面那次重写删五句时把它一起带走了（注释却还写着"人对不对 → 分完什么样"，
         *   两边对不上，看注释的人会以为这事还在）。后果在浏览器里抓到：
         *   模型的思考原文排成「总结 → 怎么派的 → 待分配 → 还能怎么选」——
         *   返回值里 `怎么选的` 那一段的两组按钮（换条件选人 / 调这批多大）
         *   **无处可归，被丢到最末尾**，它还为此在思考里自我推翻了一次。
         *   ⇒ ⛔ 改这段时别再把段序压成一段：`modelFacts` 顶层就是两段，描述必须跟它一致。
         *
         * ⭐ 顺序的**判据不是文风**（产品 2026-08-15）：选人那一档他一动 = **新的一版**，
         *   而分法上的调整是在**这一版上**改。反过来讲，先讲的分法在他动一下之后整个作废。
         *
         * 🔴 **给模型的理由里⛔ 不许夹产品事实** —— 当天用四种写法各跑了三遍：
         *   ① 原句「选人那一段之所以在前：那几档他一动就是**新的一版**，而分法上的调整
         *      是在这一版上改…」→ 顺序和穿插都对，但**产品事实被原样讲给了主管**：
         *      「这两处一动就是新的一版。」（与 2026-08-08 那次 note 混指令同一类：
         *      给模型的**理由**它会当成**内容**）。而这件事主管本来就会看到 ——
         *      `narrow` 节点的 why 写着「点哪一条都是重新选一版」，讲一遍是读两遍。
         *   ② 整句删掉 → **六次运行没有一次对**：一次两组按钮压根没开
         *      （正文写成「想只选其中一类人重出、或改本批人数，说一声就行」——
         *      而按钮存在的意义就是免掉他这句打字），其余几次三组按钮堆在正文之前、
         *      `show_sheet` 还跑到引导之前。
         *   ③ 换成纯祈使句（「⛔ 别攒到最后一起开，⛔ 也别把上面这个顺序倒过来」）
         *      → 三次里零次全对。**没有理由的禁令扶不住它。**
         *   ④ 保留「之所以在前」的**因果结构**、只切掉两个产品事实
         *      （现在这句：「他在那一段动一下，你后面讲的分法就白讲了」）
         *      → 三次全部逐组穿插，两次顺序也对，零泄露。
         *   ⇒ 承重的是**因果结构**，⛔ 不是那两个产品事实，也⛔ 不是祈使句的强度。
         * ⚠️ 剩下那半句说的是**你这次白讲**，不是产品怎么运转 —— 改写它时守住这条：
         *   ⛔ 别把「新的一版 / 这一版上改 / 会重出」这类机制词放回来。
         *
         * 🔴 **开放的粒度是「一件」不是「一段」**（2026-08-15 当天第二次实测）。
         *   这句原来写的是「在**它所属的那一段**讲完之后开放」—— 而「选人」这一段里
         *   **有两件**（换个条件选 / 本批人数）。给了段这个粒度，模型就按段攒：
         *   思考原文「Both 换个条件选 and 本批人数 belong to 选人 section,
         *   so calling them together after that section seems fine」，
         *   两排按钮于是叠在同一段末尾。
         * ⚠️ `show_guidance` 自己的描述**一直是对的**（"讲完其中一类紧接着调一次、
         *   ⛔ 别攒着一起调"）—— 两句打架时模型选了这边。
         *   ⇒ ⛔ 这里别再出现比"一件"更粗的量词：粒度只该由 `show_guidance` 定，
         *   这句话的职责仅仅是"别攒到最后"。
         */
        description:
          '从召回池按条件选出一批人，算出一版分配方案：谁分给谁、每人几条、谁卡着没发。' +
          '\n什么时候调：他说出要哪一格的人（治疗项 + 时间档）就调。' +
          '他要换条件重来（只选其中一类、改人数、改时限）也是**重调这个工具出新的一版**,' +
          '⛔ 不是在上一版已经排好的人里再挑。' +
          // 🔴 2026-08-14：这两句里原来各有一个「基数」——**它就列在提示词的禁说清单里**，
          //   而这里是直接递到模型眼前的词。实测原话:「比估算的**基数**（120）少得多」
          //   「系统…估的**基数**是 45」。⇒ 禁令拦不住你自己在描述里用的词。
          '\n⚠️ 本批人数取**小的那个**：估出来的那个数 vs 符合条件的总人数。' +
          '⛔ 所以这一格人不够时，把人数往上调没有任何作用 —— 卡住的是候选，不是你设的数。' +
          '\n⛔ 它不写任何数据，只是算一版方案。' +
          '\n\n开头先一句话说清：这一版是什么、有没有要他动手的 —— 多数时候看完这一句就够了,' +
          '后面那些是给他要动手时看的。' +
          '\n再往下**先讲这批人是怎么选出来的、这批有多大**（几个人、几天打完，够不够这些人打）,' +
          '**然后才讲人是怎么分下去的**，最后让他看这一版的完整明细。' +
          '\n可动手的事**一件一件**开放：说完它管的那件事，当场就开放它。' +
          '一段里有两件就分两次 —— 两件一起开，他得回头去认哪个引导对应哪句话。' +
          '\n⚠️ 选人那一段之所以在前：他在那一段动一下，你后面讲的分法就白讲了。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            /**
             * 🔴 **`clinicId` 参数已删（2026-08-14）—— 结构上不可能填错，胜过写一句"别编"。**
             *
             * 原描述让模型"只用 get_current_user 返回的 clinicIds 里的值"，但那个返回值
             *   **只有 uuid、没有名字** —— 主管说「给建国门那家出一版」时，模型手里根本没有
             *   「名字 → id」的映射。⇒ 这个参数只有两种填法：照抄一个它不懂含义的 uuid，
             *   或者编一个。**没有一种是"用对"**，那它就不该出现在模型面前。
             * ⚠️ 诊所由服务端定：前端传的 activeClinicId（主管现在看的那家）→ 兜底他名下第一家。
             * ⛔ 要恢复"按诊所选"，正解是给一个**带名字的 enum**（取值域 = 他有权限的那几家），
             *   ⛔ 不是把这个裸 id 参数放回来。
             */
            potentialTreatment: {
              type: 'string',
              description: POTENTIAL_TREATMENT_DESC,
            },
            temperature: {
              type: 'string',
              enum: [...TEMPERATURE_TOOL_VALUES],
              description: TEMPERATURE_TOOL_DESC,
            },
            personaTags: {
              type: 'string',
              description:
                '在初选之上追加的画像条件。' +
                '他要按画像收窄、但没说清切哪一刀时，先用 get_cohort_attributes 看各类各多少人，' +
                '把分布报给他，再带着定下来的条件重出一版。\n' +
                // ⚠️ 维度字典**必须给全**：少给一份，模型就得自己猜 key/value,
                //   而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
                PERSONA_TAGS_DESC,
            },
            minSpendYuan: {
              type: 'number',
              // ⚠️ ≤0 会被 cohortWhereSql 当成"没传"整条跳过(`minSpendYuan > 0` 那道判断),
              //   传 0 期待"不限"是碰巧对,传 -1 期待"全都要"也是碰巧对 —— 干脆不让它进来。
              exclusiveMinimum: 0,
              description:
                '累计净消费的下界，单位是元，严格大于（等于不算）。' +
                '\n这个数只能来自他。他说了数额就照传；他只说「消费高的」这类没有数额的话，' +
                '先取这批人的消费分布报给他，由他定 —— 门槛不同选出来的是完全不同的一批人，' +
                '而拿到名单之后他无从判断这个数是哪来的。' +
                '\n门槛属于这一批：各格人群的消费分布能差好几倍，同一个数换一格就不成立。',
            },
            /**
             * ⚠️ 这三个的关系**有歧义**，实现见 assignment-proposal.service：
             *   batchSize = targetCount ?? (在岗人数 × dailyCalls × expiresInDays)
             *   本批人数  = min(batchSize, 符合条件的总人数)
             * ⇒ 传了 targetCount，dailyCalls 就完全不起作用；expiresInDays 则**身兼两职**。
             *   2026-08-14 走查：原描述只写了各自是什么，没写它们互相怎么影响。
             */
            /**
             * 🔴 2026-08-14 走查：这两条原来都写着「不传自动**沿用上一次**的值」——
             *   **早就不是这样了**。2026-08-12 产品定「不再沿用上一次」，
             *   `propose` 里 `basis` 只可能是 `explicit` / `default`，`inherited` 那一支
             *   只为读老快照保留（见 basisNote 上那段注释）。
             * ⚠️ 说错的后果不是"少说一句"：模型会**故意不传**，以为能继承他上次说的
             *   「这批 200 人」，而实际是回落到公式/默认值 —— 数变了，两边都不报错。
             */
            targetCount: {
              type: 'number',
              minimum: 0,
              description:
                // ⚠️ 举例里不带具体数字（原文是「他明确说「这批 200 人」时才传」）：
                //   例子紧挨着的就是要填的那一格,200 是个白给的锚 ——
                //   而"他说了数字"这个判据本身不需要数字来说明。
                // ⚠️ 点名那个估法,别写「下面那个公式」:模型看到的是 `properties` 里
                //   各自独立的一段描述,谁在"下面"没有任何保证 —— 指过去指空了不报错。
                '本批发多少人，只在他说了具体数量时传。' +
                '\n传了就直接按这个数，「在岗人数 × 每天几通 × 时限」那个估法整个不参与，' +
                'dailyCalls 一起传也不起作用。' +
                '\n不传就走那个估法。每一批都重新估，他上一次说的数不会带到这一批。',
            },
            dailyCalls: {
              type: 'number',
              // ⚠️ 服务端本来就夹在 1–60(`Math.min(60, Math.max(1, …))`)——
              //   写进 schema 是让它**填不进来**,⛔ 不是再说一遍那句「1–60」。
              minimum: 1,
              maximum: 60,
              description:
                '每位客服每天打几通，只用来估这一批发多少人（在岗人数 × 它 × 时限）。' +
                '他调这个估法时传。' +
                '\n乘法由工具做，报给他的人数以返回值为准。' +
                '\n它不限制任何人这批能接多少条，也不参与谁分给谁；传了 targetCount 时它不起作用。',
            },
            expiresInDays: {
              type: 'number',
              minimum: 1,
              // 90 天与落库那份 schema 对齐(CreateAssignmentRequest 的 max(90))——
              // ⛔ 别只在这儿放宽:提案能出、确认那一步会被打回来,而错在几十秒之后才出现。
              maximum: 90,
              description:
                // ⚠️ 同上:举例不带具体天数（原文是「给 5 天」）
                // 🔴 2026-08-19:到期**不再退回池子**。这句话是模型转述给主管的原料,
                //   说错了它就会替系统许一个不会兑现的承诺(沿革见 plan.module 的墓碑)。
                '这批单子要在几天内打完，只在他说了天数时传。' +
                '\n过了这个天数单子不会被收走，仍在原来那位客服手上，只是记为超期。' +
                '不传按系统默认；他上一次说的天数不会带到这一批。' +
                '\n它同时是估人数那个式子里的一项（在岗人数 × 每天几通 × 它），' +
                '所以没传 targetCount 时，改天数会把本批人数一起改掉。',
            },
            agentOverrides: {
              type: 'object',
              additionalProperties: {
                type: 'object',
                properties: {
                  // ⚠️ **允许 0**(= 这轮不给他),⛔ 别写成 exclusiveMinimum:
                  //   服务端 sanitizeOverrides 特意用的是 `>= 0` 而不是 posInt。
                  maxThisBatch: { type: 'number', minimum: 0 },
                  // 与批次时限同一个上限;超了会被 sanitizeOverrides **静默丢掉**这一条
                  expiresInDays: { type: 'number', minimum: 1, maximum: 90 },
                },
              },
              /**
               * 🔴 2026-08-14 走查，这段原描述里**三处是错的**：
               *   ① 让模型去调 `list_agents` —— 这个工具**不存在**（真名 `get_agents`）。
               *   ② 「把她从表里去掉」就能恢复正常 —— 实现是
               *      `{...baseline.agentOverrides, ...input.agentOverrides}`（**合并**，
               *      见 assignment-proposal.service），省略只是沿用旧值，撤不掉。
               *   ③ 拿具体人名举例（见文件头「例子两分」：正例过期有害）。
               */
              description:
                '针对个别客服的调整，形如 {userId: {maxThisBatch, expiresInDays}}。' +
                '他点名某位客服说「这批最多给他几条」「他那批给几天」时传。' +
                '\nmaxThisBatch 是本批最多给他几条，0 表示这轮不给他。它只管这一批，' +
                '不是他长期能接多少的上限。' +
                '\n键用 userId，从 get_agents 取。写成姓名的那一条会被丢掉，不报错 ——' +
                '他拿到的是一版没按他说的算的方案。' +
                '\n这些调整会被记住，下一批继续按它算。要让某人恢复正常，' +
                '传他的空对象 {userId: {}}：只是不传他等于沿用，' +
                '而传一个很大的数会被记成新的调整一路带下去。',
            },
            // ⚠️ 服务端夹在 0–0.2(`Math.min(0.2, Math.max(0, …))`);范围写进 schema 之后
            //   描述里就不用再念一遍那两个数了 —— 结构管得住的事别再写成一句话。
            exploreRatio: {
              type: 'number',
              minimum: 0,
              maximum: 0.2,
              // ⚠️ ⛔ 别把「探索配额」这个词写进来:它就在提示词的禁说清单里,
              //   而参数名 `exploreRatio` 已经够当句柄了 —— 描述只说它**做什么**。
              description: '本批留出多大比例的名额给规则没选中的人。他没提这件事就不传。',
            },
          },
        }),
        execute: async (args: unknown) => {
          const a = (args ?? {}) as {
            potentialTreatment?: string;
            temperature?: TemperatureValue;
            personaTags?: string;
            minSpendYuan?: number;
            expiresInDays?: number;
            dailyCalls?: number;
            agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
            targetCount?: number;
            exploreRatio?: number;
          };
          // ⚠️ 诊所**不从模型来**（参数已删，见 inputSchema 上那段）：
          //   主管界面上正看着的那家 → 兜底取他名下第一家。
          const clinicId = input.activeClinicId ?? scope.clinicIds[0];
          if (!clinicId) {
            return '当前登录人没有绑定诊所，无法出确认单 —— 请主管确认数据范围。';
          }
          const sheet = await this.proposals.propose(scope, { ...a, clinicId });
          // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
          //   前端确认时原样回传，这样重连/重试都命中同一个幂等键。
          const requestId = `pa_${scope.userId}_${randomUUID()}`;
          push({ type: 'assignment_sheet', requestId, sheet });
          // 记下这一版有哪几类，供 show_guidance 校验 + 解析成 signalKey（见上面的闭包注释）
          // ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**：只有 action 层进模型上下文。
          const actions = sheet.signals.filter((s) => s.tier === 'action');
          guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
          guidanceKeys = actions.map((s) => s.key);
          sheetPushed = true;
          /**
           * ⭐ 回给模型的是**结构化事实**，⛔ 不再是成品句子（P2，2026-08-12）。
           *
           * 原来这里返回三段服务端拼好的话 + 十几行「原样输出、不要改写、不要合并」。
           * 两个问题：
           *   ① 成品句子有固定形状，模型学会形状就能**凭空背出来** ——
           *      实测背出过「已撤销批次：收回 9 条。」，而撤销工具一次都没调。
           *      结构化事实编不出来（JSON 里的具体值瞎写一眼就对得出来）。
           *   ② 「照抄」浪费了模型唯一不可替代的能力：把事实组织成人话。
           *
           * ⛔ **这个返回值里一个字的指令都不许有** —— 措辞规范集中在系统提示词
           *   （assistant-prompts 的「怎么写」一节），⛔ 别再往返回值里塞。
           * ⛔ 仍然不含任何 planId / 患者姓名：那些走侧信道给界面。
           */
          return JSON.stringify(
            {
              确认单已呈现: true,
              /**
               * ⚠️ 事实，不是叮嘱：主管点确认之前一条都没真的分下去。
               *
               * 🔴 **键名原来叫「已写入数据库」，2026-08-14 走查抓到模型对主管说
               *   「这只是提案，**尚未写入数据库**」** —— 「写入数据库」只在代码里存在，
               *   主管不认识。同一天刚把提示词里的「写库」换成人话，而**真正的出处在这里**：
               *   ⇒ 「⛔ 不想让它说的东西就别做成键名」不只管提示词，键名是同一个面。
               * ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
               */
              已经分下去了: false,
              ...modelFacts(sheet),
            },
            null,
            2,
          );
        },
      });

      /**
       * ⭐ **确认单卡片摆在哪** —— 同样由模型决定（2026-08-13）。
       *
       * ═══ 为什么需要它 ═══════════════════════════════════════════
       * 工具调用**必然发生在文本之前**（模型得先拿到结果才知道说什么），
       * 所以卡片到达的顺序天然是「卡片 → 文字」。界面此前靠两个补丁纠正：
       * 把 sheet 挪到消息末尾 + **流式期间先不渲染**。
       * 后者的代价是：卡片要等**整轮**跑完才出现 —— 而这一轮现在还含着几次
       * `show_guidance` 的往返，卡片因此白等十来秒。
       *
       * ⇒ 模型讲完「这批人怎么来的 / 分完什么样」→ 调这个 → 卡片当场长在那儿，
       *   ⛔ 不用等后面的引导节点跑完。
       *
       * ⚠️ 这个工具**只决定位置**：确认单本身在 propose_assignment 就已经推给界面了，
       *   草稿状态机（active / superseded / confirmed）一个字都不经过这里。
       * 🔴 **漏调不会让卡片消失** —— 没摆的话它仍旧落在这条消息末尾（就是今天的行为）。
       *   ⇒ 最坏情况 = 今天。这一点对确认单尤其重要：它是整条链上唯一会写库的东西。
       */
      tools.show_sheet = tool({
        /**
         * 🔴 2026-08-14 按裸台验过的写法重写（是什么 / 什么时候别用 / 边界）。
         *
         * 删掉的三处 —— ⛔ 别抄回来：
         *   「摆在你当前写到的位置」「卡片会掉到整条消息最后」→ **空间词**。模型看不见页面，
         *     给了位置词汇它就会向主管描述位置，而描述必然错（实测说过「已经摆在**上方**」，
         *     卡片却在那句话下面）。位置改说「在你正文里的位置」。
         *   「一轮只调一次」「每出一版都要调一次」→ 这是"**调几次**"，归**生产者**
         *     （propose_assignment 的讲述顺序里那句「讲完人是怎么分下去的，让他看完整明细」）。
         *
         * 🔴 但"**说完再调**"这条下限 2026-08-14 实测**必须留在这儿**：
         *   只写在 propose 的讲述顺序里兜不住 —— 当轮模型 propose 一返回就调了它，
         *   思考里写得很清楚:「明细已呈现。现在给要点说明。」⇒ 它把这个工具读成了
         *   "先把明细摆出来"，而顺序那句在另一个工具的描述里，此刻不在它眼前。
         *   ⚠️ 后果不只是读起来倒:锚点落在第一段正文**之前**会被判成"无根"，
         *   卡片退回老路（等整轮跑完、钉在末尾）—— 这个锚点的意义当场归零。
         *   返回值里的「已摆在：你刚写到的地方」→ 返回值是泄漏风险最高的一层
         *     （`已插入选项按钮`、`<按钮位置 />` 两次翻车都出在这），只回一个事实。
         */
        description:
          '让他看到这一版的完整明细：谁分给了谁、每人几条 —— 他要据此决定确不确认。' +
          '\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「明细从这里开始看」。' +
          '\n⛔ 这一版还没算出来之前别调：那时没有明细，他会看到一块空的。' +
          '\n⛔ **算出来了也别急着调** —— 先把这一版是怎么排的讲出来，讲完再调：' +
          '明细是给他核对你刚说的那几句用的。反过来，他得先读完一屏名单，' +
          '再回头找你为什么这么排。' +
          // 🔴 2026-08-14 实测:同一条消息里写了「明细在下面」又写了「明细已经在上面了」。
          //   两句**当时都是对的**,但这个工具越强调"位置有含义",模型越会把它当成
          //   可以说出口的东西 —— 而它说的位置是**屏幕**位置,那个它看不见。
          '\n⛔ 调了就够了，别再用话去指它（"在下面""在上面""这里"）：' +
          '位置由这次调用定，你**看不见**它最后落在他屏幕的哪一侧；' +
          '指错了他会去找一个不存在的地方。' +
          '\n⛔ 它不改任何数据；没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调，' +
          '它的含义全在位置上。',
        inputSchema: jsonSchema({ type: 'object', properties: {} }),
        execute: async () => {
          // ⚠️ 能拦的用**结构**拦，⛔ 别只靠上面那句 ⛔；拦下来还要说清现在该干什么
          if (!sheetPushed) return '这一轮还没算出方案，没有明细可看。先算一版。';
          this.logger.log('工具调用：show_sheet');
          push({ type: 'assignment_sheet_anchor' });
          return JSON.stringify({ 已收到: true });
        },
      });

      /**
       * ⭐ 「要主管定的」那几条的**按钮插在哪** —— 由模型自己决定（2026-08-12 产品定）。
       *
       * ═══ 为什么是工具，不是前端去猜 ═══════════════════════════════
       * 按钮和它的解释必须挨着：主管读完「389 人卡在待分配，不处理 = 这批不发给他们」，
       * 下一眼就该看到那四个选项。而**谁知道这句话写在哪** —— 只有刚写完它的模型。
       * 前端曾经用 `text.indexOf(标题)` 去猜（已删）：模型换个措辞就匹配不上，
       * 而匹配不上就渲染不出来 = 把「还有 389 人没发出去」静默藏起来。
       *
       * ⇒ 模型写完那一条 → 调这个工具 → 界面在**此刻这个位置**长出按钮 → 继续往下写。
       *   这和 `render_artifact` 是同一个模式：工具调用即"在流里插一个界面块"。
       *
       * ⚠️ **漏调不会丢东西**：没被插进正文的那几条，仍然照旧显示在确认单卡片上
       *   （界面按已插入的编号去重）。⇒ 这个工具优化的是**位置**，⛔ 不是可见性。
       */
      tools.show_guidance = tool({
        /**
         * 🔴 2026-08-14 三处改动，都是裸台验过的：
         *   ① `编号`（number）→ `id`（中文标识 enum）。序号没有语义，模型得自己记账
         *      "我讲的这段是第几条"；标识让它按**意思**对上。沿革见 `GUIDANCE_KIND`。
         *   ② 位置的说法从"插在你当前写到的位置"改成"**落在你正文里的哪个位置**" ——
         *      前者是**空间词**，而模型看不见页面，给了词汇它就会向主管描述位置并说错。
         *   ③ 返回值只回一个事实：原来回「已插入选项按钮: <标题>」，模型下一步会把这句
         *      标题**再复述一遍**；「位置: 你刚写到的地方」更是白给它一套空间词汇。
         * ⚠️ 「什么时候调」里那句「⛔ 别攒着一起调」是**实测有效**的（合并成一段讲时
         *   两组按钮会挤在一起），⛔ 别当成啰嗦删掉。
         */
        description:
          '把他接下来可以动手的事开放给他 —— 他直接点就行，⛔ 不必再打字把需求描述一遍。' +
          '\n什么时候调：讲完其中一类，**紧接着**调一次、传它的 id；再讲下一类，再调一次。' +
          '\n⚠️ 这个调用**落在你正文里的哪个位置是有含义的** —— 它标记「到这里为止，这一类我讲完了」。' +
          '⛔ 所以别攒着一起调 —— 哪怕只攒了两类，前一类的动作也会落到后一类的说明后面。' +
          '\n⛔ 还没讲到的别提前调 —— 他看到一件没头没尾的事，只会回头来问。' +
          '\n⛔ 它不改任何数据；没有返回值给你用 —— ⚠️ 但这**不代表**可以事后补调，' +
          '它的含义全在位置上。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            id: {
              type: 'string',
              // ⚠️ 判据是固定的四类,所以 enum 是静态的 —— ⛔ 别做成"随每版提案变"，
              //   工具清单在 propose_assignment 跑之前就建好了，那时还不知道这一版有哪几类。
              enum: [...GUIDANCE_KINDS],
              description:
                '要开放哪一类：' +
                '换个条件选 = 这一格可以只选其中一类人，重出一版；' +
                '本批人数 = 这一批发多少人，可以调；' +
                '最忙和超期 = 分完之后最忙那位手上的量，超过了「每天几通 × 时限」；可以调时限或每天几通。' +
                '待分配 = 有人卡着没发出去、等他定去向；' +
                '\n⚠️ 只能开放**这一版真的有**的那几类；没有的会被退回，并告诉你这一版有哪几类。',
            },
          },
          required: ['id'],
        }),
        execute: async (args: unknown) => {
          const kind = String((args as { id?: unknown })?.id ?? '');
          const at = guidanceKinds.indexOf(kind);
          if (at === -1) {
            // ⚠️ 错误里**把这一版真有哪几类列出来** —— 这是模型自纠的唯一线索
            if (!sheetPushed) return '这一轮还没算出方案，没有可开放的事。先算一版。';
            return guidanceKinds.length
              ? `这一版没有「${kind}」。只有：${guidanceKinds.join('、')}。`
              : '这一版没有可开放的事，一件都不用调。';
          }
          // ⭐ 本地工具也要留痕 —— 「模型到底调没调」是这条生产线最贵的一类问题，
          //   没有这行日志就只能靠界面上有没有按钮反推（见上面 MCP 工具那段注释）。
          this.logger.log(`工具调用：show_guidance ${kind}`);
          // ⚠️ 侧信道直接给**解析好的 signalKey** —— 界面不用再做 标识→key 的映射，
          //   而 key 也不会进模型上下文（侧信道不经过模型）。⛔ 别让两边各解析一次。
          push({ type: 'assignment_guidance', signalKey: guidanceKeys[at] });
          // ⚠️ 只回事实。⛔ 别在这里写「继续往下写」之类的叮嘱 ——
          //   工具返回值里塞指令，模型照抄就把内部指令贴进了主管的对话框（2026-08-08 栽过）。
          return JSON.stringify({ 已收到: kind });
        },
      });

      /**
       * ⭐ 助手**直接改已经呈现的那张确认单**。
       *
       * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
       * 在此之前助手只能回一句"我做不到，你先确认再逐条退回" —— 那是把界面能做的事
       * 推回给主管手工做，而这张卡就在他眼前（2026-08-03 走查）。
       *
       * ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文（故意的）。
       *   匹配由界面用自己手里那份确认单完成，结果它会回一句话进对话。
       * ⚠️ 能做的只有卡片上能做的那几件（删/改派/改时限）。
       *   换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
       */
      /**
       * ⭐ **次要业务线的做法，模型自己来取**（pull 式加载，见 assistant-playbooks 顶部）。
       *
       * 索引就是下面 `topic` 的取值域 —— 每个取值旁边写着"他问什么时候用它"，
       * 所以模型**不需要提示词里再有一句**「遇到 X 先取做法」。
       */
      tools.open_playbook = tool({
        description:
          '取一篇做法：某一类活按什么顺序做、他要的到底是什么。' +
          '\n什么时候调：他提的事落进下面某一类的范围里，**动手之前**先取一次。' +
          '\n⛔ 它不查任何数据、不改任何东西 —— 取回来的是做法，事实仍然要去调对应的工具。' +
          '\n同一件事一轮取一次就够了，取回来的内容这一轮一直有效。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            topic: {
              type: 'string',
              enum: [...PLAYBOOK_TOPICS],
              description: `要取哪一篇：${PLAYBOOK_INDEX}`,
            },
          },
          required: ['topic'],
        }),
        execute: async (args: unknown) => {
          const topic = String((args as { topic?: unknown })?.topic ?? '');
          const pb = PLAYBOOKS[topic];
          // ⚠️ 取错了要**把有哪几篇列出来** —— 这是模型自纠的唯一线索(同 show_guidance)
          if (!pb) return `没有「${topic}」这一篇。有的是：${PLAYBOOK_TOPICS.join('、')}。`;
          this.logger.log(`工具调用：open_playbook ${topic}`);
          return pb.正文;
        },
      });

      /**
       * 🔴 **查眼前那张确认单现在什么样** —— 2026-08-15 加。
       *
       * ═══ 为什么非要有它 ═══════════════════════════════════════════
       * 确认之前那张单是**草稿**：主管拖一个人、点一个 ×、改一次时限，
       * 数就变了，而这些改动**只存在于浏览器的卡片组件里**。
       * 模型此前唯一的信息来源是对话里那几行「确认单已更新:…」，于是：
       *   ① 它得**自己把几行累加**才能得出当前值（实测原话
       *      「系统排的 313 人…加上刚还回专属客服的 182 人…一共 495 人」——
       *      这次算对了，但那是心算，错了不报错）；
       *   ② 手动拖动 / 点 × / 改时限这几条路**当时连回声都没有**，
       *      它连累加的材料都没有 —— 卡片上写着 3 天，它嘴上还说 1 天。
       * ⇒ 现在两条一起补：回声带上当前值（推），这个工具随时能取（拉）。
       *
       * ⚠️ 它查的是**随本次请求捎来的那份快照**，⛔ 不是服务端的什么状态 ——
       *   服务端没有这个东西，也⛔ 不许为它去开一条"草稿回传"的写路径。
       * ⚠️ 无参数：只有一张单可查（现役那张）。⛔ 别加 requestId 之类的参数，
       *   模型没有办法知道该填哪个（同 `propose_assignment.clinicId` 被删掉那次的判断）。
       */
      tools.get_current_sheet = tool({
        description:
          '看他眼前那张确认单**现在**什么样：已排好几条、落在几位客服头上、' +
          '还有几个待他定、时限几天、带没带福利。' +
          '\n什么时候调：他在卡片上动过手（拖人、移出、改时限、改福利）之后，' +
          '你要报数、要判断"还差什么"、或者他问"现在什么情况"，先调它。' +
          '\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
          '\n⛔ 它不改任何东西，只是看一眼。',
        inputSchema: jsonSchema({ type: 'object', properties: {} }),
        execute: async () => {
          // ⚠️ 没有 = **他手上根本没有确认单**，⛔ 别回一个空壳让模型以为是"全 0 的单"
          if (!input.sheetState) {
            return '他手上现在没有确认单 —— 还没出过，或者那一版已经作废了。';
          }
          this.logger.log('工具调用：get_current_sheet');
          return JSON.stringify(sheetSnapshotFacts(input.sheetState), null, 2);
        },
      });

      tools.edit_assignment_sheet = tool({
        description:
          '改他眼前那张确认单：移出谁、改派给谁、改时限、设本批福利。' +
          '\n什么时候调：他对这一版提出具体改动就调。这几件事界面上他自己也能做，' +
          '而他开口说了就是要你替他做完 —— 让他先确认再逐条退回，等于把几十次拖拽推回给他。' +
          '\n一条指令由三件事拼起来：**选谁**（select）· **干什么**（action）· **给谁**（to，只有改派要）。' +
          '⚠️ 按这三问填，⛔ 别去背短语 —— 每一格该填什么、有哪些取值，' +
          '各自的参数说明里写着，那里也写了选错会怎样。' +
          '\n\n它只改这一版的**局部**。换人群、改本批人数都要重算，那走 propose_assignment。' +
          '\n患者用姓名或病历号指代，客服用姓名 —— 匹配由界面拿它手里那份单子完成。' +
          // ⚠️ 这里说的是**先后**不是**位置**：位置（"上面"）是他屏幕上的事，你看不见，
          //   写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
          '\n⚠️ 改完**什么都不用补**：界面会把结果直接发进对话，而且**先于你这段话**到他那儿。' +
          '⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' +
          '他读到那句时结果早就在了,顺序是倒的。' +
          '\n⚠️ 批次**已经确认分配**之后：只有 `set_benefit` 还能改（福利只影响此后生成的话术,' +
          '界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过"）;' +
          '人员和时限**在这张单上改不了** —— 单子已经在客服手上。' +
          '要换人、改时限、移出,走**调整各客服在手的单**那条路;' +
          '⛔ 别说成"只能整批撤销重分",那条路早就不是唯一的了。整批收回才是撤销。' +
          '⛔ 这两种情况都以界面回的那句为准，别自己判断成没成。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            ops: {
              type: 'array',
              description: '一组修改，按给出的顺序依次执行。',
              items: {
                type: 'object',
                properties: {
                  select: {
                    type: 'object',
                    description: '这条修改作用在哪些人身上。',
                    properties: {
                      /**
                       * ⭐ 取值的**含义写在取值上** —— 与 `show_guidance` 的 `id` 同一个做法。
                       *   四个值摆在一起、各自带一句话，模型是在选而不是在猜；
                       *   判据只写在工具描述正文里时，它读到这一格已经隔了很远。
                       */
                      group: {
                        type: 'string',
                        enum: ['patients', 'agent', 'pending', 'batch'],
                        description:
                          'patients=点名的这几位患者；agent=某位客服名下的全部；' +
                          'pending=待分配那一组；batch=整批。' +
                          '\n「把某某移出这批」里的某某默认是**患者**。' +
                          '只有他明说「客服某某不参与 / 某某的单都别给他」才用 agent ——' +
                          'agent 一次动的是几十条，而同一个名字可能既是本批客服又是本批患者，' +
                          '选错了他会以为只动了一条。',
                      },
                      patients: {
                        type: 'array',
                        items: { type: 'string' },
                        description: '患者姓名或病历号，可给多个。group=patients 时必填。',
                      },
                      agent: {
                        type: 'string',
                        description: '客服姓名。group=agent 时必填。',
                      },
                    },
                    required: ['group'],
                  },
                  action: {
                    type: 'string',
                    enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
                    description:
                      'assign=改派，配 to；remove=移出本批；set_expiry=改时限，配 days；' +
                      'set_benefit=设本批福利，配 text，且 select 必须是整批。',
                  },
                  to: {
                    type: 'object',
                    description:
                      '改派给谁，action=assign 时必填。' +
                      '\nowner = 各自回自己的专属客服，一条专属关系都不动；' +
                      'balance = 谁手上少先给谁。' +
                      // ⚠️ 关键词逐个列全:这五个都来自实测那次选反(他说「各自分给各自的专属客服」,
                      //   助手用了铺平,18 个人被散给了 17 位别人)。少一个就少一条判据。
                      '\n两者结果正好相反，而他一眼就看得出来：他说「各自 / 自己的 / 本来的 /' +
                      ' 专属 / 谁的客户归谁」指的是 owner，说「平均分 / 谁手上少给谁 / 铺下去 /' +
                      ' 随机分一分」指的是 balance。' +
                      '\n把患者从他的专属客服手里挪走是他的决定，他明确要求了才用 balance。' +
                      '\nbalance 是按在手量铺的，不是随机 —— 同一张单每次算出来都一样。' +
                      '他说「随机分一分」时也用它，但界面回报里说的是铺给了谁几条，照那句转述。',
                    properties: {
                      mode: { type: 'string', enum: ['owner', 'balance'] },
                      agents: {
                        type: 'array',
                        items: { type: 'string' },
                        description:
                          '把候选限定在这几位客服（姓名），mode=balance 时可选，只点一位就是都给他。' +
                          '不给 = 本批所有在岗，含这轮一条都没分到的那些 —— 他们手上恰恰最空。' +
                          '\nmode=owner 时不看这个参数：收谁由患者自己的专属关系决定。',
                      },
                    },
                    required: ['mode'],
                  },
                  // 与批次时限同一个上限(落库那份 schema 是 max(90))——
                  // ⛔ 只写在描述里的话,91 会一路走到确认才被打回来
                  days: {
                    type: 'number',
                    minimum: 1,
                    maximum: 90,
                    description: '新的时限天数，action=set_expiry 时必填。',
                  },
                  text: {
                    type: 'string',
                    description:
                      '福利原文，action=set_benefit 时必填；空串表示撤掉本批福利。' +
                      '它挂在整批上，会进这批人的话术。' +
                      '\n照他的原话写 —— 条件、期限、承诺都由他说了才有。',
                  },
                },
                required: ['select', 'action'],
              },
            },
          },
          required: ['ops'],
        }),
        execute: async (args: unknown) => {
          const ops = (args as { ops?: unknown[] })?.ops;
          if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作，确认单未改动。';
          push({ type: 'assignment_sheet_edit', ops });
          /**
           * ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
           *
           * 🔴 2026-08-13:原来这里写着「请等那句结果,⛔ 不要自己宣布已完成」——
           *   模型把它当话说了出去:「已把这条修改交给界面执行，等它回报结果再告诉您。」
           *   而界面的结果**就在那句话上面一行**(它是同步注入的),读起来是倒的:
           *   结果已经出来了,助手却还在说"等结果"。
           *   ⇒ 指令归工具描述,返回值只留事实(这条规矩 2026-08-08 就定了,这里漏网)。
           */
          return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
        },
      });

      // ── 本地工具:调整单(调整各客服在手的单)────────────────────
      // ⛔ 与上面确认单那三件**完全隔离**:分开的工具、分开的侧信道事件、分开的界面组件。
      //   两者形状像但语义不通(调整不建批次、没有福利、没有"本批多大"),
      //   缝在一起必然要给一半参数配空值,而那一半迟早有人在某个分支上忘了判。
      /// 本轮出过调整单没有 —— show_rearrange 只有在出过之后才有意义
      let rearrangePushed = false;
      tools.propose_rearrange = tool({
        description:
          '把此刻**已经在客服手上**的单摆成一版可编辑的调整单:谁手上多少条、其中几条超期。' +
          '\n他要动已经分下去的单时用它,要换看的范围(本来只看某几位,现在要看全队)时也用它。' +
          '\n眼前已经有一版的时候不用 —— 再来一次是**整版重出**,他刚在上面调好的全没了,' +
          '而他往往只想挪一两个人;他这时候说的具体改动,落在那一版上,由改调整单那件工具做。' +
          '\n它摆出来的是**原样**:谁手上什么样就是什么样。' +
          '摆完把你看到的说给他听(谁手上多少、谁最多、有没有超期),然后等他说怎么调。' +
          '\n把患者从一位客服手里挪到另一位手里,是**关系层面的决定**,只有他有资格拍。' +
          '你先替他排好了,他看到的就不再是现在什么样,而是你觉得该什么样 ——' +
          '他要么照单全收(那等于你替他定了),要么得先把你排的拆回去,才能说自己的想法。' +
          '\n它跟从池子里选人发单是两件事:那件把没人管的发下去,这件把已经有人管的换个安排。' +
          '\n它不写任何数据。要真的生效,得他在调整单上点确认。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            agents: {
              type: 'array',
              items: { type: 'string' },
              description:
                '只看这几位客服手上的(客服姓名)。他点了名就传,没点名就不传,那样是全队在手。' +
                '\n传了之后这一版**只有**这几位的单,别人手上的一条都不在里面;' +
                '要把其中一位手上的分给别人,收的那几位也得在这个名单里,否则他们在这一版上不存在。',
            },
          },
        }),
        execute: async (args: unknown) => {
          const names = ((args as { agents?: unknown })?.agents ?? []) as unknown[];
          const clinicId = input.activeClinicId ?? scope.clinicIds[0];
          if (!clinicId) {
            return '当前登录人没有绑定诊所,无法出调整单 —— 请主管确认数据范围。';
          }
          /**
           * ⚠️ 模型手上只有**姓名**,而服务端按 userId 取数 —— 姓名→id 在这里解析,
           *   ⛔ 别把 userId 塞进工具入参让模型去背:那条路 `agentOverrides` 已经踩过
           *   (写成姓名的那一条被静默丢掉,主管拿到一版没按他说的算的方案)。
           */
          const roster = await this.roster.list(scope, clinicId);
          const wanted = names.filter((n): n is string => typeof n === 'string' && !!n.trim());
          const matched = wanted
            .map((n) => roster.agents.find((a) => a.name === n.trim())?.userId)
            .filter((v): v is string => !!v);
          const missed = wanted.filter((n) => !roster.agents.some((a) => a.name === n.trim()));
          if (wanted.length > 0 && matched.length === 0) {
            return JSON.stringify({
              没出调整单: true,
              原因: '这些名字在本诊所的客服名册里一个都没找到',
              没找到的: missed,
            });
          }
          const sheet = await this.rearrange.draft(
            scope,
            clinicId,
            matched.length > 0 ? matched : undefined,
          );
          push({ type: 'rearrange_sheet', operationId: sheet.operationId, sheet });
          rearrangePushed = true;
          this.logger.log(
            `工具调用:propose_rearrange 范围=${sheet.scope} 在手=${sheet.total} 超期=${sheet.overdueTotal}`,
          );
          return JSON.stringify({
            ...rearrangeFacts(sheet),
            ...(missed.length > 0 ? { 名册里没找到的名字: missed } : {}),
          });
        },
      });

      tools.edit_rearrange_sheet = tool({
        description:
          '改他眼前那张调整单:把谁换给谁、改时限、移出。' +
          '\n**他说出了要动哪些,才用这件。** 他点了名、或者圈了一组(某位客服手上的、' +
          '超期的那些),都算说出来了。' +
          '\n他只说了不满意什么(手上不平、谁太多了、调一调),没说要动谁、给谁、动几条 ——' +
          '那三个数是他要定的。你替他定了,他要么照单全收,要么得先把你定的拆回去才能说自己的想法。' +
          '这时候把摆出来的事实说给他听,等他这一句。' +
          '\n这些改动落在**眼前那一版**上。重新出一版会把他刚调好的全清掉。' +
          '\n一条指令由三件事拼起来:**选谁**(select)· **干什么**(action)· **给谁**(to,只有换人要)。' +
          '每一格该填什么、填错会怎样,写在各自的参数说明里。' +
          '\n这一版没有福利这件事 —— 这里的人来自若干不同批次,而福利是挂在批次上的。' +
          '\n患者用姓名或病历号指代,客服用姓名;匹配由界面拿它手里那份单子完成。' +
          '\n改完什么都不用补:界面会把结果直接发进对话,而且**先于你这段话**到他那儿 ——' +
          '你说"已完成"或"等结果再告诉您"的时候,结果早就在他眼前了。' +
          '\n它仍然不写数据:要真的生效,得他在调整单上点确认。',
        inputSchema: jsonSchema({
          type: 'object',
          properties: {
            ops: {
              type: 'array',
              description: '一组修改,按给出的顺序依次执行。',
              items: {
                type: 'object',
                properties: {
                  select: {
                    type: 'object',
                    description: '这条修改作用在哪些单上。',
                    properties: {
                      group: {
                        type: 'string',
                        enum: ['patients', 'agent', 'overdue', 'all'],
                        description:
                          'patients=点名的这几位患者;agent=某位客服手上的全部;' +
                          'overdue=这一版里已经超期的那些;all=这一版全部。' +
                          '\n他点一个名字时,默认那是**患者**;只有他说的是某位客服手上的整批,才用 agent。' +
                          'agent 一次动的是几十条,而同一个名字可能既是客服又是患者 ——' +
                          '选成 agent 时他会以为只动了一条。',
                      },
                      patients: {
                        type: 'array',
                        items: { type: 'string' },
                        description: '患者姓名或病历号,可给多个。group=patients 时必填。',
                      },
                      agent: {
                        type: 'string',
                        description: '客服姓名。group=agent 时必填。',
                      },
                    },
                    required: ['group'],
                  },
                  action: {
                    type: 'string',
                    enum: ['move', 'set_expiry', 'remove'],
                    description:
                      'move=换给别的客服,配 to;set_expiry=改时限,配 days;' +
                      'remove=移出(收回池子,谁都不挂着)。',
                  },
                  to: {
                    type: 'object',
                    description:
                      '换给谁,action=move 时必填。' +
                      '\nbalance = 谁手上少先给谁;agents 只给一位就是都给他。' +
                      '\n收的人**必须在这一版里**:这一版是点名几位客服出的时候,' +
                      '别人在这一版上不存在,写上去也落不上。',
                    properties: {
                      mode: { type: 'string', enum: ['balance'] },
                      agents: {
                        type: 'array',
                        items: { type: 'string' },
                        description:
                          '把候选限定在这几位客服(姓名)。不给 = 这一版里的其他人,' +
                          '按谁手上少先给谁。',
                      },
                    },
                    required: ['mode'],
                  },
                  days: {
                    type: 'number',
                    minimum: 1,
                    maximum: 90,
                    description:
                      '新的时限天数,action=set_expiry 时必填。' +
                      '\n从今天起算,不是在原来的天数上加。改短了会当场制造超期,界面会把这件事标出来。',
                  },
                },
                required: ['select', 'action'],
              },
            },
          },
          required: ['ops'],
        }),
        execute: async (args: unknown) => {
          const ops = (args as { ops?: unknown[] })?.ops;
          if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,调整单未改动。';
          /**
           * 🔴 **⛔ 这里绝不能拦「本轮还没出过调整单」**(2026-08-20 实测栽过)。
           *
           * `rearrangePushed` 只在**同一轮** chat() 里为真(tools 每轮现建)。而调整单
           * 绝大多数时候是**上一轮**推出去的 —— 主管看着它才说「胡剑波分配到张萌这里」。
           * 加了这道闸,那句话进来时这件工具回一句「先出一版」,模型只能照办:
           *   实测链路 = 试 edit → 被回绝 → propose_rearrange(**整版重出**,旧的当场作废)
           *             → 再 edit。主管眼里是"我只让他挪一个人,他把整张单换了"。
           * ⚠️ 确认单那边的 `edit_assignment_sheet` **从来没有这道闸**,就是这个原因。
           * ⇒ 判断"有没有一张还能改的单"是**界面**的事(它手里那份才是权威,
           *   见 chat-blocks 的 findActiveRearrange);找不到就静默不动,⛔ 不该由这里猜。
           */
          push({ type: 'rearrange_sheet_edit', ops });
          return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
        },
      });

      tools.show_rearrange = tool({
        description:
          '让他看到这一版调整单的完整明细:谁手上哪些人、各自还剩几天 —— 他要据此决定确不确认。' +
          '\n这个调用**落在你正文里的哪个位置是有含义的**:它标记「明细从这里开始看」。' +
          '\n这一版还没出来时调,他看到的是一块空的。' +
          '\n明细是给他核对你刚说的那几句用的 —— 先把这一版是什么、你替他动了什么讲出来,讲完再调。' +
          '\n位置由这次调用定,而你**看不见**它最后落在他屏幕的哪一侧;再用话去指它' +
          '(在下面、在上面),他会去找一个不存在的地方。' +
          '\n它不改任何数据,没有返回值给你用。',
        inputSchema: jsonSchema({ type: 'object', properties: {} }),
        execute: async () => {
          // ⚠️ 措辞刻意**不说「先出一版」**:调整单往往是上一轮推的,那句话会把模型
          //   推去 propose_rearrange 整版重出(见 edit 那件工具里的红字)。这里只陈述事实。
          if (!rearrangePushed) {
            return '这一轮没有新出的调整单 —— 明细在上面那一版里,不用再摆一次。';
          }
          this.logger.log('工具调用:show_rearrange');
          push({ type: 'rearrange_sheet_anchor' });
          return '';
        },
      });
    }

    // 本地"渲染"工具（不走 MCP）：模型把自包含 HTML 片段交给前端，在沙箱 iframe 里渲染成卡片/报表。
    // html 通过 tool-call 入参流式到前端;execute 仅回执，模型据此继续给一句话总结。
    tools.render_artifact = tool({
      description:
        '把一段自包含 HTML(<body> 内部片段)画成一张图表卡片，展示给使用者。' +
        '适合召回池列表、患者画像卡、分析报表（可含图表）。' +
        // 🔴 2026-08-19 修:原文写的是「PAC 主题 teal #0D9488」—— **那不是 PAC 的主色**。
        //   前端 2026 年把 `teal-*` 整体改名成 `brand-*`(263 处)、主色是 PANTONE 286 C
        //   `#0032A0`(见 globals.css 的品牌色阶),而这句留在服务端没跟着改。
        //   ⇒ 助手画出来的每一张图表都是青绿色的,跟同屏的界面不是一套色。
        // ⚠️ 这里只能写死十六进制:模型拿不到 CSS 变量。改品牌色时**这一处要跟着改**。
        '\n排版用 Tailwind 工具类，强调色用 PAC 品牌蓝 #0032A0,白底圆角卡片、留白舒适。' +
        '\n运行环境已注入 Tailwind 与 Chart.js:⛔ 不要写 <html>/<head>/<!DOCTYPE>,也不要自行引入它们。' +
        '\n图表用 Chart.js。<canvas> **必须**放进固定高度容器' +
        '(如 <div style="position:relative;height:300px"><canvas></canvas></div>)' +
        '并在 options 里设 maintainAspectRatio:false —— 给图表用百分比或满屏高度' +
        '(h-full / h-screen / height:100%)会让它无限拉高。' +
        '\n数据全部内联写进 HTML,只用工具返回的真实值，手机号只给中间打了码的;这个片段不能访问外部网络(⛔ 勿 fetch)。' +
        '\n标记力求精简：只留要紧的那几项，同类条目用最短的结构，生成更快。',
      inputSchema: jsonSchema({
        type: 'object',
        properties: {
          title: { type: 'string', description: '卡片标题，可不给。' },
          html: {
            type: 'string',
            description:
              '`<body>` 内部的 HTML 片段：Tailwind 工具类排版，图表用 `<canvas>` + `<script>`。' +
              '完整要求见本工具说明。',
          },
        },
        required: ['html'],
      }),
      // ⚠️ 返回值会回到模型上下文,它可能照着复述 —— 所以这句也得是人话
      execute: async () => '已经画在界面上了。',
    });

    // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
    const resolved = this.provider.resolve(input.modelId ?? 'deepseek');
    /**
     * ⚠️ 日志打**解析后的型号**(`deepseek-v4-flash`),⛔ 不是逻辑键(`deepseek`)。
     *   裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
     *   「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
     */
    this.logger.log(
      `assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`,
    );

    /**
     * ⭐ **每一轮往 `agent_invocations` 落一行** —— 落的是「该调的工具调没调」，
     *   ⛔ 不是聊天记录。在此之前这件事线上一次都没留过痕，只能靠临时跑批。
     *
     * ⚠️ 落库**绝不能影响这一轮对话** —— 全程 try/catch 吞掉，失败只打日志：
     *   主管正等着一版方案，⛔ 不该因为审计写不进去而看不到结果。
     * ⚠️ `scope` 缺失（比如某些内部入口）就整条跳过 —— hostId/tenantId 是必填列。
     */
    const toolTrace: ToolTraceEntry[] = [];
    // 统一给**所有**工具（MCP 拉来的 + 本地那几个）套一层计时留痕。
    // ⚠️ 装在这里而不是各自 execute 里：漏一个就等于那个工具永远不出现在验收数据里。
    for (const name of Object.keys(tools)) {
      // AI SDK 的 ToolSet 是判别联合，execute 的入参类型在这一层无法窄化 ——
      // 计时包装对参数**不做任何解释**，只透传，所以用 any 收口，边界只在这两行。
      /* eslint-disable @typescript-eslint/no-explicit-any */
      const t = tools[name] as { execute?: (...a: any[]) => any } | undefined;
      const orig = t?.execute?.bind(t);
      if (!t || !orig) continue;
      t.execute = async (args: any, opts: any) => {
        const t0 = Date.now();
        try {
          const r = await orig(args, opts);
          toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
          return r;
        } catch (e) {
          toolTrace.push({ name, ok: false, ms: Date.now() - t0, args: briefToolArgs(args) });
          throw e;
        }
      };
      /* eslint-enable @typescript-eslint/no-explicit-any */
    }

    const scope = input.scope;
    const callKey = assistantCallKey(!!input.permissions?.includes(Permission.PLAN_DISPATCH));
    const snapshot = slimInputSnapshot(input.messages, {
      activeClinicId: input.activeClinicId,
      activePatientId: input.activePatientId,
    });
    const startedAt = Date.now();
    let invocationId: string | undefined;
    if (scope) {
      try {
        invocationId = await this.recorder.start({
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          kind: 'assistant',
          callKey,
          promptVersion: ASSISTANT_PROMPT_VERSION,
          modelProvider: resolved.provider,
          modelName: resolved.modelId,
          /**
           * 🔴 **同一次对话的每一轮共用一个 run id** —— 这是「哪几轮是一批」的唯一可靠依据。
           * ⚠️ 曾经每轮各生成一个，只能靠 `turnNo` 归 1 反推会话边界 —— 那是启发式：
           *   并发对话行是交错的，前端裁剪历史时 turnNo 还会错位。
           */
          workflowRunId: input.conversationId ?? randomUUID(),
          inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
          // ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注（全量存会按平方涨）
          inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
          // ⛔ systemPrompt / promptTemplate 一律不落:前者每次一模一样（靠 promptVersion 回查），
          //    后者的内容已经在 inputSnapshot.userText 里了。
        });
      } catch (e) {
        this.logger.warn(`invocation start 失败（不影响对话）：${e instanceof Error ? e.message : e}`);
      }
    }

    const { model } = resolved;
    return streamText({
      model,
      /**
       * ⭐ 整份系统提示词在**一处**装配（装置 → 诚实 → 语气 → 角色 → 现场），
       *   渠道规则拼在最后 —— 它是对既有约束的**追加**，不该插在中间改变阅读顺序。
       */
      system: [
        buildSystemPrompt({
          permissions: input.permissions ?? [],
          voice: input.voice ?? this.config.get('ai.assistantVoice', { infer: true }),
        }),
        /**
         * 🔴 **他此刻正开着谁** —— 拼在现场之后、渠道之前（2026-08-15 加）。
         *
         * 他在患者详情页说「这通电话怎么开口」「这个患者为什么被召回」时**句子里没有名字**。
         * 界面本来就知道是谁（那几条例句正是拿他的名字预填的），却从没发给过模型 ——
         * ⇒ 上下文走旁路，⛔ 别指望它从对话里猜（同 `activeClinicId` 那条纪律）。
         *
         * ⚠️ 措辞两件事都要说：**默认落点**（没点名时指的是他）+ **可以被推翻**
         *   （他点了别人的名字就以他说的为准）—— 只写前半句会让它连"查另一个人"都不敢。
         * ⛔ 不进 `buildSystemPrompt` 的五层：那五层是**按什么会让它变**分的，
         *   而这一行每次请求都不一样，是**此刻的现场状态**，不是规则。
         */
        ...(input.activePatientId
          ? [
              `他现在正开着一位患者（patientId: ${input.activePatientId}）。` +
                `他说「这个患者」「这通电话」而没点名时，指的就是这一位。` +
                `他点了别人的名字，就以他说的为准。`,
            ]
          : []),
        ...(input.channelExtra ? [input.channelExtra] : []),
      ].join('\n\n'),
      messages: input.messages,
      tools,
      // 防失控：最多 8 步工具循环。
      // 🔴 到顶必须**报出来**（控制器按 finishReason='tool-calls' 发 step_limit 事件）——
      //   静默截断会让模型停在「我先查一下」之后，而主管以为它查完了。
      stopWhen: stepCountIs(MAX_TOOL_STEPS),
      abortSignal: input.abortSignal,
      onFinish: (ev) => {
        void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, ev);
      },
      onError: ({ error }) => {
        void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, {
          errorMessage: error instanceof Error ? error.message : String(error),
        });
      },
    });
  }

  /**
   * 一轮结束时回填 `agent_invocations` —— **失败只打日志，⛔ 绝不外抛**。
   * 它挂在 streamText 的 onFinish/onError 上，抛出去会污染正在给主管吐字的那条流。
   */
  private async finishInvocation(
    invocationId: string | undefined,
    modelId: string,
    startedAt: number,
    toolTrace: readonly ToolTraceEntry[],
    ev: {
      text?: string;
      /**
       * 🔴 **多步时必须拼所有步的文本** —— `OnFinishEvent` 继承的是**最后一步**的
       *   `StepResult`，`ev.text` 只有末步那段。模型在工具之间穿插说的话
       *   （「我先查一下」「这批人比预想的少」）全在前面的步里，只取 `ev.text` 就丢了。
       * ⚠️ 实测那轮 5 次工具调用、6924 输出 token，落库只有 60 字 —— 那次凑巧没丢，
       *   因为它把话都留到了最后一步。⛔ 别指望它每次都这样。
       */
      steps?: ReadonlyArray<{ text?: string; toolCalls?: ReadonlyArray<{ toolName?: string }> }>;
      finishReason?: string;
      // AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
      totalUsage?: Record<string, unknown>;
      usage?: Record<string, unknown>;
      errorMessage?: string;
    },
  ): Promise<void> {
    if (!invocationId) return;
    try {
      const u = ev.totalUsage ?? ev.usage ?? {};
      const num = (k: string): number => (typeof u[k] === 'number' ? (u[k] as number) : 0);
      const promptTokens = num('inputTokens');
      const completionTokens = num('outputTokens');
      const cachedInputTokens = num('cachedInputTokens');
      const { yuan, priceMissing } = estimateCostYuan(
        this.config.get('ai', { infer: true }).priceTable,
        modelId,
        promptTokens,
        completionTokens,
        cachedInputTokens,
      );
      if (priceMissing) {
        this.logger.warn(`价目表里没有 ${modelId} —— 本轮助手成本按兜底价估算,数字**不准**。`);
      }
      await this.recorder.end(invocationId, {
        /**
         * 🔴 **这一列才是落这行的理由** —— 「该调的工具调没调」是助手这层的验收判据。
         *   ⛔ 不放工具**返回值**：确认单那类肥载荷正是量级失控的源头。
         */
        output: {
          toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
          toolCallCount: toolTrace.length,
          /**
           * ⭐ **每一步的形状** —— 这是「一类一调、紧挨着调」唯一能验证的形式。
           *   只看 toolCalls 的次数看不出对错：3 次 show_guidance 可以是分 3 步各讲一段后各调
           *   （对），也可以是同一步里一口气调 3 次（错，三排按钮叠在同一段末尾）。
           */
          steps: stepShapes(ev.steps) as unknown as Prisma.InputJsonValue,
          finishReason: ev.finishReason ?? null,
        } as Prisma.InputJsonValue,
        outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)),
        promptTokens,
        completionTokens,
        totalTokens: num('totalTokens') || promptTokens + completionTokens,
        cachedInputTokens,
        reasoningTokens: num('reasoningTokens'),
        costYuan: yuan,
        latencyMs: Date.now() - startedAt,
        status: ev.errorMessage ? 'failed' : 'succeeded',
        errorMessage: ev.errorMessage,
      });
    } catch (e) {
      this.logger.warn(`invocation end 失败（不影响对话）：${e instanceof Error ? e.message : e}`);
    }
  }

  /** 桌宠环境观察发言 —— 无工具、限长、流式；失败由前端静默降级（宠物只是不说话）。 */
  petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
    const { model } = this.provider.resolve('deepseek');
    return streamText({
      model,
      system: PET_SYSTEM_PROMPT,
      prompt: `环境观察：${input.observation}`,
      // 注：思考型模型 reasoning 也计入 output token,上限给宽；台词长度靠系统提示词约束（≤30 字）
      maxOutputTokens: 500,
      temperature: 1.0,
      abortSignal: input.abortSignal,
    });
  }
}
