diff --git a/docs/build.md b/docs/build.md index 3336e095..56e2eef1 100644 --- a/docs/build.md +++ b/docs/build.md @@ -494,7 +494,7 @@ HTTP 非 200 响应解码为协议错误,保留 HTTP 状态、google.rpc code Build 页面的对话面板由写代码助手回复。助手带有服务端的系统提示词,用量计入 Build 对话的额度:免费账户为免费请求,Google AI 订阅账户为订阅额度,与 Playground、Build 应用代理分别计算。服务把它作为第三个通道 `assistant`,经模型名后缀 `-build` 调用。 -本节的 RPC 都位于 MakerSuite 服务,地址为 `https://alkalimakersuite-pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.MakerSuiteService/`,方法为 `POST`,正文为 `application/json+protobuf` 稀疏数组:protobuf field N 对应 JSON 索引 N-1,中间未设置的 field 写 `null`。 +本节的 RPC 除 `DeleteApplet` 外都位于 MakerSuite 服务,地址为 `https://alkalimakersuite-pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.MakerSuiteService/`;`DeleteApplet` 位于应用服务,地址为 `https://alkalimakersuiteapplets.pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.applets.AppletService/DeleteApplet`。方法为 `POST`,正文为 `application/json+protobuf` 稀疏数组:protobuf field N 对应 JSON 索引 N-1,中间未设置的 field 写 `null`。 ### 目录与调度 @@ -538,7 +538,9 @@ ListModels: gemini-3.8-flash(主能力码含 48) - field 9 以内置工具禁止说明开头:写代码助手服务端指令声明的 25 个工作区工具(`run_command`、`write_to_file`、`view_file`、`search_web` 等)作用于与对话无关的空应用,说明列出这些工具名并禁止调用;请求带 Google Search 工具时列表不含 `search_web`,并在说明末尾允许调用 `search_web`;请求声明函数且 tool choice 不是 `none` 时列表不含 `ask_question`,模型写出调用代码块后以它结束本轮 - `CodeAssistantOffline` 没有客户端函数声明字段,对话轮中的函数调用与结果 Part 不进入模型输入。请求声明函数且 tool choice 不是 `none` 时,函数声明以 JSON 写入下面的函数调用说明,接在禁止说明之后(中间空一行) - 对话轮中的函数调用与结果 Part 转录为代码块文本:同一轮内相邻的调用合为一个 `function_calls` 块,相邻的结果合为一个 `function_results` 块,元素分别为 `{"name","arguments"}` 与 `{"name","response"}`;转录在上传附件与计算 WAA binding 之前完成 -- 写代码助手只把 field 1 中的 user 轮放入模型输入,model 轮被丢弃,多个 user 轮合为一轮;应用中以前各轮由上游保存,按原角色进入模型输入,见“对话与应用”。服务把 field 1 中的 model 轮去掉思考与空 Part 后,首尾加上 `\n` 与 `\n` 两个文本 Part,以 user 轮发送;本次或以前提交到该应用的对话轮中有这样转录的 model 轮时,field 9 的说明之后另起一段写入下面的模型轮说明 +- 写代码助手只把 field 1 中的 user 轮放入模型输入,model 轮被丢弃,多个 user 轮合为一轮,合并后的消息超过约 33k token 时只有开头部分进入模型输入;field 9 不截短(33 万 token 完整进入);应用中以前各轮由上游保存,按原角色进入模型输入,见“对话与应用” +- 新建应用时最后一组之前还有对话轮的,这些轮次去掉思考与空 Part 后转录在 field 9 末尾,field 1 只提交最后一组:field 9 的说明之后另起一段写入下面的对话历史说明,系统提示之后另起一段写入 `` 与 `` 包住的各轮,user 轮为 `\n<正文>\n\n`,model 轮为 `\n<正文>\n\n`,同一轮的多个 Part 以换行连接。转录轮次中的附件写为 `[attachment <序号>]`,附件本身按序号放在 field 1 第一轮开头,每个附件前是它的 `[attachment <序号>]` 文本 Part;之后在该应用中继续时,field 9 按本次请求转录同样多的开头分组,客户端回传的历史格式不同(例如合并了相邻的 user 轮)时按回传的格式转录 +- 最后一组是 model 轮,或其本地 Token 数超过 24576 时,不在已有应用中继续:新建应用,最后一组也转录在 field 9 中,field 1 只提交用户轮 `Continue the conversation from the last turn of the conversation history at the end of your instructions.`,转录轮次中的附件放在这一轮开头 - 本进程读到的回复中,`search_web` 步骤的查询与摘要按请求所用 API key、该轮之前对话轮的角色与正文摘要、回复正文保存(最多 1000 条回复);之后以同一 API key 发送的请求中,某个 model 轮之前的对话与正文都和某条回复相同时,在该轮开头补回文本 Part `search_web results for "<查询>":\n<摘要>`,每次搜索一个 ````text @@ -552,7 +554,7 @@ Right after the block, call your ask_question tool once with the question "Run t ```` ````text -Your own earlier replies in this conversation, including your function_calls blocks, appear in the user message between and . Continue from the latest content and never write these tags yourself. +The conversation before the first user message of this chat is transcribed at the end of these instructions between and : user turns between and , and your own earlier replies, including your function_calls blocks, between and . Attachments in it are numbered like [attachment 1] and are attached in the same order at the start of the first user message of this chat. Continue the conversation from the latest message and never write these tags yourself. ```` - Google Search 工具不写入请求:写代码助手按需调用内置的 `search_web`,搜索步骤按“任务流”输出为 grounding;其他 Google 工具在发送前丢弃 @@ -567,11 +569,11 @@ CreateApplet(对话没有可继续的应用时) → 应用 ID CodeAssistantOffline(WAA proof) → 任务 ID StreamCodeAssistantOfflineGeneration → 步骤快照流,读到本轮回复结束或上游关闭 StreamCodeAssistantOfflineGeneration (消息列表在回复完成前结束时再请求一次) -CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文、问题与函数调用时追加用户轮,最多 3 次) +CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文、问题与函数调用,或上游步骤在已输出正文后失败时追加用户轮,最多 3 次) 流关闭后在后台: - CancelCodeAssistantOfflineGeneration (已取得任务 ID 且流没有正常解码完成时) -巡检: - DeleteApplet (闲置的应用) + CancelCodeAssistantOfflineGeneration (已取得任务 ID、流没有正常解码完成且任务没有以失败步骤结束时) +巡检、删除账户、新建应用后写入记录失败: + DeleteApplet (应用服务) ``` - 请求头的集合、顺序与取值和 Playground 文本请求的 `GenerateContent` 相同;权益头 `X-AIStudio-G1-Tier` 只随 `CodeAssistantOffline` 发送(Pro 为 `TIER1`、Ultra 为 `TIER2`、Plus 为 `TIER0`,Free 不带) @@ -585,10 +587,11 @@ CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文 服务按请求所用 API key 与对话历史把请求对应到应用: - 对话轮按连续的 model 轮与其余轮次分组,逐组累积 SHA-256 摘要。每组计入非思考正文 Part 直接连接后按空白归一的结果、函数调用的名称与键排序的参数、函数结果键排序的内容,其余 Part 去掉思考签名计入,内联数据以其 SHA-256 计入;调用 ID 不计入,客户端以不同格式回传同一回复时摘要相同 -- 本轮回复完整结束、没有以 `malformed_function_call` 结束、也没有在函数调用之后继续输出正文时,本进程把“请求的对话 + 本轮回复的正文与函数调用”的摘要记为该应用可以继续的位置 -- 新请求从后往前找到最近一个摘要与记录相同、之后还有新轮次的 model 轮组时,在该应用中继续:field 1 只提交该组之后的轮次,field 6 为 `2`、field 14 为 `[2]`、field 21 为应用 ID;记录的位置被取走,回复完整结束后移到新回复之后,同时到达的另一请求找不到记录 -- 找不到时新建应用并提交全部对话轮,包括第一轮、重新生成、编辑或压缩过的历史、另一个 API key、上一轮回复没有完整结束与服务重启之后;对话所在账户没有空闲槽位或不可用、请求被调度到其他账户时,放弃原应用并在新账户新建 +- 本轮回复完整结束、没有以 `malformed_function_call` 结束、没有在函数调用之后继续输出正文、也没有在上游步骤失败后接着写时,本进程把“请求的对话 + 本轮回复的正文与函数调用”的摘要记为该应用可以继续的位置 +- 新请求最后一组之前最近的 model 轮组摘要与记录相同时,在该应用中继续:field 1 只提交该组之后的轮次,field 6 为 `2`、field 14 为 `[2]`、field 21 为应用 ID;记录的位置被取走,回复完整结束后移到新回复之后,同时到达的另一请求找不到记录 +- 找不到时新建应用,最后一组之前的对话轮转录在 field 9 中,见“请求映射”;包括第一轮、重新生成、编辑或压缩过的历史、回到较早的回复另起分支、另一个 API key、上一轮回复没有完整结束或在步骤失败后接着写过、最后一组过长或是 model 轮与服务重启之后;对话所在账户没有空闲槽位或不可用、请求被调度到其他账户时,放弃原应用并在新账户新建 - 新建的应用 ID 以资源类型 `assistant-applet` 记入账户运行状态。生成服务启动时与之后每 10 分钟,巡检删除闲置超过 1 小时、或本进程没有可继续对话的应用并删除记录,`DeleteApplet` 返回 404 时同样删除记录;进行中的请求所用应用保留,没有空闲槽位的账户跳过本轮;删除失败时以 WARN 记录 “闲置写代码助手应用删除失败: <错误>”,记录保留到下一轮 +- `CreateApplet` 之后写入记录失败时,请求以该错误结束,并删除刚建的应用;管理端删除账户时,账户有应用记录的先停止向该账户调度新请求,等进行中的请求结束后删除全部应用与其上的对话,再删除账户,应用删除失败时以 WARN 记录 “账户写代码助手应用删除失败” #### 附件上传 @@ -636,7 +639,7 @@ CodeAssistantOffline、StreamCodeAssistantOfflineGeneration(回复没有正文 | 2 | WAA proof | | 6 | 新建应用的首轮为 `1`,继续时为 `2` | | 8 | `models/<基础模型 ID>` | -| 9 | 自定义指令:内置工具禁止说明、函数调用说明、模型轮说明与系统提示 | +| 9 | 自定义指令:内置工具禁止说明、函数调用说明、对话历史说明、系统提示与转录的对话历史 | | 12 | 应用 ID | | 14 | 首轮为 `[2,null,2]`,继续时为 `[2]` | | 16 | `1` | @@ -675,11 +678,11 @@ WAA binding 与 Playground `GenerateContent` 相同:contents 各 Part 依次 [""] ``` -第一行是 `CancelCodeAssistantOfflineGeneration` 的正文,第二行是 `DeleteApplet` 的正文;`DeleteApplet` 成功响应为 `[]`,之后该应用返回 404 `[5,"Requested entity was not found."]`。 +第一行是 `CancelCodeAssistantOfflineGeneration` 的正文,第二行是 `DeleteApplet` 的正文;`DeleteApplet` 成功响应为 `[]`,之后该应用返回 404 `[5,"Requested entity was not found."]`。MakerSuite 服务也有正文相同的 `DeleteApplet`,对带开发环境的应用返回 403 `[7,"The caller does not have permission"]`;这类应用在 `GetApplet` 响应 field 1 的应用中 field 12 为 `["https://ais-dev-<…>.run.app"]` 预览地址,官网打开应用时由 `ProvisionAndInitializeApplet` 开通,应用服务的 `DeleteApplet` 同样删除它们。生成刚结束的几秒内,应用服务的 `DeleteApplet` 返回 500 `[13,"Internal error encountered."]`,服务每秒重试,最多 10 次。 - 任务流正文关闭时结束请求,包括正常读完、客户端断开与停止序列截断;`CreateApplet` 之后任一步失败时同样结束。回复完整结束时记录可继续的位置,否则放弃应用,由巡检删除 -- 已取得任务 ID 且流没有正常解码完成时,在后台以独立的 1 分钟期限发送一次 Cancel,失败时以 WARN 记录 “Build 写代码助手清理失败”,字段为 `account`、`method`、`applet` 与 `error` -- `DeleteApplet` 只由“对话与应用”中的巡检发送 +- 已取得任务 ID、流没有正常解码完成且任务没有以失败步骤结束时,在后台以独立的 1 分钟期限发送一次 Cancel,失败时以 WARN 记录 “Build 写代码助手清理失败”,字段为 `account`、`method`、`applet` 与 `error` +- `DeleteApplet` 由“对话与应用”中的巡检、删除账户与写入记录失败时发送 ### 任务流 @@ -723,7 +726,17 @@ Part 的 field 2 为文本,field 13 为 `1` 或 `true` 时是思考。只修 9. 消息列表结束后读取流尾状态,code 0 为成功,其他 code 按“错误、额度与能力边界”的表换算 HTTP 状态。上游有时在模型步骤仍为状态 `2` 时正常结束消息列表;此时以同一任务 ID 与应用再请求一次 `StreamCodeAssistantOfflineGeneration`,已完成的任务立即返回状态为 `1` 的最终快照与用量,按同一组规则继续解码,已输出的正文与函数调用不重复输出;重新打开的任务流仍在模型步骤完成前结束时,已解出函数调用或写出正文则以已有内容结束本轮,任务流关闭后取消生成任务,下一轮新建应用并带全部历史 10. 本轮回复结束或流关闭时回复已完成:依次输出暂缓的文本、记录的搜索 grounding、用量之和,再以 `stop` 结束(无法解码的 `function_calls` 块时为 `malformed_function_call`);回复没有完成、也没有函数调用与正文时返回协议错误 “流结束前没有完成的模型回复” -回复完成时没有正文、问题与函数调用(例如只有思考)且没有以 `malformed_function_call` 结束时,在同一应用中继续:field 1 只含用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`,再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 +一次任务的回复完成时没有输出正文、问题与函数调用(例如只有思考)且没有以 `malformed_function_call` 结束时,在同一应用中继续:field 1 只含用户轮 `Your last turn ended without any visible reply. Continue now and give your reply.`,再读取新任务的任务流,按同一组规则解码,最多 3 次;用量的输入与缓存取最后一次,输出为各次之和。 + +本轮已输出正文、还没有输出函数调用时出现失败步骤且不带额度标记的,同样在同一应用中继续:field 1 只含下面的用户轮,`` 中为本轮已输出正文的最后至多 20000 个字符;新任务的输出接在已输出的正文之后,失败任务中已完成步骤的搜索来源与用量照常输出,用量按上一段的规则合并,搜索记录一并保存,与上一段的追加合计最多 3 次,仍失败时以该失败步骤的错误结束。失败之前只有思考时返回错误,由生成层重新生成: + +````text +Your last turn was cut off by an internal error. The user already received your reply up to the end of this text: + +<已输出正文的末尾> + +Continue the reply right after it without repeating it. +```` 用量之和:输入为各步骤未命中缓存的输入与缓存命中之和,缓存为缓存命中之和,输出为输出之和,总数为输入加输出。 @@ -735,6 +748,6 @@ Part 的 field 2 为文本,field 13 为 `1` 或 `true` 时是思考。只修 | --- | --- | | 附件上传、`CreateApplet`、`CodeAssistantOffline` 或任务流返回非 200 | 按“错误、额度与能力边界”解码与换算,进入换号与冷却流程,冷却 scope 为 `-build` 模型 ID | | 流尾状态非 0 | 同上;首个语义事件之后以流内 error 结束 | -| 步骤 field 6 非空、状态不是 `3` 且本轮还没有输出函数调用 | HTTP 502,消息为 “生成步骤失败: <上游消息>”,按 5xx 处理;带额度标记时为 HTTP 429 | +| 步骤 field 6 非空、状态不是 `3` 且本轮还没有输出函数调用 | HTTP 502,消息为 “生成步骤失败: <上游消息>”,按 5xx 处理;带额度标记时为 HTTP 429;已输出正文且不带额度标记时先按“任务流”在同一应用中继续 | | 应用 ID、任务 ID 缺失或流结构不符 | 协议错误,HTTP 502 | | 流关闭时没有完成的模型回复、函数调用与正文 | 协议错误,HTTP 502 | diff --git a/docs/logging.md b/docs/logging.md index d1807624..f100e801 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -259,6 +259,7 @@ WARN account@example.com 账号切换 | 模型=gemini-3.7-flash | `账户添加 | 1/2`、`2/2`、`账户添加完成` | 隔离登录、保存认证状态、同步模型目录 | | `账户登录 | 1/2`、`2/2`、`账户登录完成` | 更新当前账户认证材料与 Worker 实例 | | `账户配置已更新`、`账户已删除` | 账户配置与运行对象已更新 | +| `账户写代码助手应用删除失败` | 删除账户前未能删除该账户的写代码助手应用,账户照常删除,原始错误保留 | | `账户模型目录同步完成` | 新目录已写入当前模型目录和账户调度状态 | | `账户模型目录等待重试` | 当前同步失败,运行期重试继续处理 | diff --git a/internal/aistudio/assistant.go b/internal/aistudio/assistant.go index 2729b0ed..7983c7f8 100644 --- a/internal/aistudio/assistant.go +++ b/internal/aistudio/assistant.go @@ -51,17 +51,29 @@ const ( assistantWorkspaceRule = "The tools listed earlier in your instructions (%s) act on an empty remote applet workspace that has nothing to do with this conversation or the user's machine. You are strictly forbidden to call any of them: do not create, view, edit or delete workspace files, run workspace commands, compile, install, deploy or generate images there. Reply to the user directly in your answer." // assistantSearchRule 是请求允许网页搜索时接在禁止说明之后的说明 assistantSearchRule = " The only exception is search_web, which you may call to search the web." - // assistantTurnOpen 与 assistantTurnClose 包住以 user 轮发送的模型历史轮 - assistantTurnOpen = "\n" - assistantTurnClose = "\n" + // assistantHistoryOpen 与 assistantHistoryClose 包住转录在自定义指令末尾的对话历史 + assistantHistoryOpen = "\n" + assistantHistoryClose = "" + // assistantHistoryTurn 是对话历史中的一轮,依次为标签、正文与标签 + assistantHistoryTurn = "<%s>\n%s\n\n" + // assistantAttachmentLabel 是对话历史中附件的编号 + assistantAttachmentLabel = "[attachment %d]" // assistantSearchTranscript 是模型历史轮中补回的一次 search_web 结果,依次为查询与摘要 assistantSearchTranscript = "search_web results for %q:\n%s" // assistantSearchHistoryLimit 是保存 search_web 结果的模型回复数上限 assistantSearchHistoryLimit = 1000 - // assistantHistoryRule 是对话含模型历史轮时写入自定义指令的说明 - assistantHistoryRule = "Your own earlier replies in this conversation, including your function_calls blocks, appear in the user message between and . Continue from the latest content and never write these tags yourself." + // assistantHistoryRule 是自定义指令转录了对话历史时的说明 + assistantHistoryRule = "The conversation before the first user message of this chat is transcribed at the end of these instructions between and : user turns between and , and your own earlier replies, including your function_calls blocks, between and . Attachments in it are numbered like [attachment 1] and are attached in the same order at the start of the first user message of this chat. Continue the conversation from the latest message and never write these tags yourself." + // assistantTurnTokenLimit 是最后一组留在 field 1 提交的本地 Token 数上限,超过时与之前的对话一起转录在自定义指令中 + assistantTurnTokenLimit = 24576 + // assistantLatestPrompt 是对话全部转录在自定义指令中时提交的用户轮 + assistantLatestPrompt = "Continue the conversation from the last turn of the conversation history at the end of your instructions." // assistantEmptyReplyPrompt 是模型一轮结束时没有正文与函数调用后在同一应用中追加的用户轮 assistantEmptyReplyPrompt = "Your last turn ended without any visible reply. Continue now and give your reply." + // assistantInterruptedPrompt 是上游步骤在已输出正文后失败时在同一应用中追加的用户轮,%s 为已输出正文的末尾 + assistantInterruptedPrompt = "Your last turn was cut off by an internal error. The user already received your reply up to the end of this text:\n\n%s\n\nContinue the reply right after it without repeating it." + // assistantPartialReplyRunes 是 assistantInterruptedPrompt 带上的已输出正文末尾的字符数上限 + assistantPartialReplyRunes = 20000 ) // assistantWorkspaceTools 是写代码助手服务端指令声明的工作区内置工具,search_web 在最后 @@ -179,8 +191,8 @@ func EncodeAssistantRequest(request GenerateRequest, defaults GenerationDefaults return json.Marshal(wire) } -// assistantToolPrompt 把禁止调用内置工具的说明与函数声明写在自定义指令开头,声明函数时允许以 ask_question 交回调用,把对话中的函数调用与函数结果改写为 function_calls 与 function_results 代码块,只保留 start 起的对话轮;写代码助手只读取 user 轮,模型历史轮去掉思考后包上标记以 user 轮发送,transcribed 时说明这一标记 -func assistantToolPrompt(request GenerateRequest, start int, transcribed bool) (GenerateRequest, error) { +// assistantToolPrompt 把禁止调用内置工具的说明与函数声明写在自定义指令开头,声明函数时允许以 ask_question 交回调用,把对话中的函数调用与函数结果改写为 function_calls 与 function_results 代码块;前 history 轮去掉思考后转录在自定义指令末尾,只提交 start 起的对话轮,全部转录时提交请模型从最后一轮继续的用户轮,start 等于 history 时其中的附件按编号放在提交的第一轮开头 +func assistantToolPrompt(request GenerateRequest, history int, start int) (GenerateRequest, error) { functions := len(request.Tools.Functions) > 0 && request.Tools.ToolConfig.Mode != "none" forbidden := assistantWorkspaceTools if request.Tools.GoogleSearch != nil { @@ -200,7 +212,7 @@ func assistantToolPrompt(request GenerateRequest, start int, transcribed bool) ( } prompt += "\n\n" + fmt.Sprintf(assistantToolProtocol, declarations) } - contents := make([]Content, 0, len(request.Contents)) + turns := make([][]Part, 0, len(request.Contents)) var pending []FunctionCall for index, content := range request.Contents { linked, err := linkFunctionResults(content, &pending) @@ -245,32 +257,61 @@ func assistantToolPrompt(request GenerateRequest, start int, transcribed bool) ( if err := flush(); err != nil { return request, err } - if linked.Role == RoleAssistant { - parts = assistantTurn(parts) - } - contents = append(contents, Content{Role: RoleUser, Parts: parts}) + turns = append(turns, parts) } - if transcribed { + if history > 0 { prompt += "\n\n" + assistantHistoryRule } if len(request.System) > 0 { prompt += "\n\n" } request.System = append([]string{prompt}, request.System...) - request.Contents = contents[start:] + var attachments []Part + if history > 0 { + var transcript string + transcript, attachments = assistantTranscript(request.Contents[:history], turns) + request.System = append(request.System, "\n\n"+transcript) + } + contents := make([]Content, 0, len(turns)-start+1) + for _, parts := range turns[start:] { + contents = append(contents, Content{Role: RoleUser, Parts: parts}) + } + if len(contents) == 0 { + contents = append(contents, Content{Role: RoleUser, Parts: []Part{{Text: assistantLatestPrompt}}}) + } + if start == history && len(attachments) > 0 { + contents[0].Parts = append(attachments, contents[0].Parts...) + } + request.Contents = contents return request, nil } -// assistantTurn 去掉模型历史轮的思考与空 Part,首尾加上模型轮标记 -func assistantTurn(parts []Part) []Part { - turn := []Part{{Text: assistantTurnOpen}} - for _, part := range parts { - if part.Thought || !part.payload() { - continue +// assistantTranscript 返回 contents 转录成的对话历史与其中的附件:各轮取 turns 中同一位置改写后的 Part,去掉思考与空 Part,附件写为编号,附件依次以编号文本 Part 与附件 Part 返回 +func assistantTranscript(contents []Content, turns [][]Part) (string, []Part) { + var transcript strings.Builder + var attachments []Part + transcript.WriteString(assistantHistoryOpen) + for index, content := range contents { + tag := "user_turn" + if content.Role == RoleAssistant { + tag = "assistant_turn" + } + var texts []string + for _, part := range turns[index] { + switch { + case part.Thought || !part.payload(): + case part.textOnly(): + texts = append(texts, part.Text) + default: + label := fmt.Sprintf(assistantAttachmentLabel, len(attachments)/2+1) + attachments = append(attachments, Part{Text: label}, part) + texts = append(texts, label) + } } - turn = append(turn, part) + fmt.Fprintf(&transcript, assistantHistoryTurn, tag, strings.Join(texts, "\n"), tag) } - return append(turn, Part{Text: assistantTurnClose}) + transcript.WriteString(assistantHistoryClose) + return transcript.String(), attachments } // scriptJSON 按页面脚本 JSON.stringify 的形式编码,保留 HTML 字符原样、不带结尾换行 @@ -284,7 +325,7 @@ func scriptJSON(value any) (string, error) { return strings.TrimSuffix(buffer.String(), "\n"), nil } -// sendAssistant 在对话所在的应用中只提交新增轮次,没有可继续的应用时新建应用并提交全部历史,返回任务流;本轮结束时没有正文与函数调用的,在同一应用中追加用户轮请模型继续,用量按续写合并;流关闭后取消未完成的任务,本轮回复完整结束时对话保存为可继续,否则放弃应用留待巡检删除 +// sendAssistant 在对话所在的应用中只提交新增轮次,没有可继续的应用时新建应用,最后一组之前的历史转录在自定义指令中,返回任务流;本轮结束时没有正文与函数调用、或上游步骤在已输出正文后失败的,在同一应用中追加用户轮请模型继续,用量按续写合并;流关闭后取消未完成的任务,本轮回复完整结束时对话保存为可继续,否则放弃应用留待巡检删除 func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, entry modelEntry) (*RPCResponse, func(io.Reader, func(Event) error) error, error) { reason := "" if request.Unary { @@ -300,12 +341,43 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent apiKey, _ := ctx.Value(apiKeyContextKey{}).(string) history := assistantHistoryDigest(request.Contents) links := assistantLinks(request.Contents) - session, start := c.assistants.claim(apiKey, request.AccountID, links) + // latest 表示最后一组也转录在自定义指令中:最后一组是模型轮,或本地 Token 数超过 assistantTurnTokenLimit + latest := false + if count := len(links); count > 0 { + tail := 0 + if count > 1 { + tail = links[count-2].end + } + var parts []Part + for _, content := range request.Contents[tail:] { + parts = append(parts, content.Parts...) + } + latest = links[count-1].model || localPartsTokens(parts) > assistantTurnTokenLimit + } + var session *assistantSession + start := 0 + if !latest { + session, start = c.assistants.claim(apiKey, request.AccountID, links) + } + // groups 是转录在自定义指令中的开头分组数 + groups := 0 + switch { + case latest: + groups = len(links) + start = len(request.Contents) + case session != nil: + groups = session.history + case len(links) > 1: + groups = len(links) - 1 + start = links[groups-1].end + } + transcribed := 0 + if groups > 0 { + transcribed = links[groups-1].end + } cleanup := &assistantCleanup{client: c, accountID: request.AccountID, session: session} - cleanup.transcribed = slices.ContainsFunc(request.Contents[start:], func(content Content) bool { return content.Role == RoleAssistant }) || - session != nil && session.transcribed request.Contents = assistantSearches.restore(apiKey, request.Contents) - request, err := assistantToolPrompt(request, start, cleanup.transcribed) + request, err := assistantToolPrompt(request, transcribed, start) if err != nil { cleanup.run() return nil, nil, fmt.Errorf("%w: %v", ErrInvalidArgument, err) @@ -321,11 +393,11 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent if err != nil { return nil, nil, err } - cleanup.session = c.assistants.open(request.AccountID, applet) + cleanup.session = c.assistants.open(request.AccountID, applet, groups) lease, _ := AccountLeaseFromContext(ctx) if err := lease.BindResource(applet, assistantAppletKind); err != nil { cleanup.run() - return nil, nil, err + return nil, nil, errors.Join(err, c.deleteAssistantApplet(ctx, request.AccountID, applet)) } } applet := cleanup.session.applet @@ -367,21 +439,48 @@ func (c *Client) sendAssistant(ctx context.Context, request GenerateRequest, ent } decoder := newDecoder(streamBody) err := DecodeAssistantStream(source, decoder, collect) - for followed := 0; err == nil && len(reply) == 0 && !decoder.overran && !decoder.malformed && followed < regenerations; followed++ { + // searches 是之前各任务完成的 search_web,interrupted 表示本轮在上游步骤失败后接着写过 + var searches []string + interrupted := false + // replied 是当前任务开始时回复中的 Part 数 + for followed, replied := 0, 0; followed < regenerations; followed++ { + prompt := assistantEmptyReplyPrompt + var failure *RPCError + if err != nil { + if !decoder.failed || answer.Len() == 0 || !errors.As(err, &failure) || failure.StatusCode != http.StatusBadGateway { + break + } + err = nil + for _, event := range decoder.recorded() { + if err = collect(event); err != nil { + break + } + } + if err != nil { + break + } + partial := []rune(answer.String()) + prompt = fmt.Sprintf(assistantInterruptedPrompt, string(partial[max(0, len(partial)-assistantPartialReplyRunes):])) + interrupted = true + } else if len(reply) > replied || decoder.overran || decoder.malformed { + break + } + replied = len(reply) + searches = append(searches, decoder.searches...) followUp := request - followUp.Contents = []Content{{Role: RoleUser, Parts: []Part{{Text: assistantEmptyReplyPrompt}}}} + followUp.Contents = []Content{{Role: RoleUser, Parts: []Part{{Text: prompt}}}} next, nextBody, follow, openErr := c.openAssistantGeneration(ctx, followUp, entry, applet, true) cleanup.generation.Store(&next) + decoder = newDecoder(nextBody) if err = openErr; err != nil { break } - decoder = newDecoder(nextBody) err = errors.Join(DecodeAssistantStream(follow.Body, decoder, collect), follow.Body.Close()) } + cleanup.completed.Store(decoder.failed || err == nil && decoder.finished && !decoder.overran) if err == nil { - cleanup.completed.Store(decoder.finished && !decoder.overran || decoder.failed) - assistantSearches.store(apiKey, history, answer.String(), decoder.searches) - if decoder.finished && !decoder.overran && !decoder.malformed { + assistantSearches.store(apiKey, history, answer.String(), append(searches, decoder.searches...)) + if decoder.finished && !decoder.overran && !decoder.malformed && !interrupted { cleanup.tip.Store(&assistantSessionKey{apiKey: apiKey, digest: assistantLinkDigest(links[len(links)-1].digest, true, reply)}) } } @@ -574,8 +673,6 @@ type assistantCleanup struct { client *Client accountID string session *assistantSession - // transcribed 表示请求结束后应用中有以 user 轮转录的模型历史轮 - transcribed bool // generation 是应用中最近提交的任务 ID generation atomic.Pointer[string] completed atomic.Bool @@ -590,7 +687,7 @@ func (cleanup *assistantCleanup) run() { if cleanup.session == nil { return } - cleanup.client.assistants.finish(cleanup.session, cleanup.tip.Load(), cleanup.transcribed) + cleanup.client.assistants.finish(cleanup.session, cleanup.tip.Load()) generation := cleanup.generation.Load() if generation == nil || *generation == "" || cleanup.completed.Load() { return @@ -782,8 +879,9 @@ func (d *AssistantStreamDecoder) decodeStep(raw json.RawMessage, path string, ev if status == 3 { return nil, nil } + d.failed = true if d.called { - d.overran, d.failed = true, true + d.overran = true return nil, nil } return nil, failed @@ -1205,18 +1303,30 @@ func (d *AssistantStreamDecoder) decodeSearch(raw json.RawMessage, path string, return nil } +// recorded 返回记录的搜索来源与各步骤用量之和 +func (d *AssistantStreamDecoder) recorded() []Event { + var events []Event + if d.grounding != nil { + events = append(events, Event{Kind: EventGrounding, Grounding: d.grounding}) + } + if len(d.usage) > 0 { + var total Usage + for _, usage := range d.usage { + total.InputTokens += usage.InputTokens + total.CachedTokens += usage.CachedTokens + total.OutputTokens += usage.OutputTokens + total.TotalTokens += usage.TotalTokens + } + events = append(events, Event{Kind: EventUsage, Usage: &total}) + } + return events +} + // End 在本轮回复结束、函数调用之后模型继续输出正文或流结束时返回扣下的正文或函数调用、搜索来源、各步骤用量之和与完成事件 func (d *AssistantStreamDecoder) End() ([]Event, error) { if !d.finished && !d.overran && !d.called && !d.wroteText { return nil, &ProtocolEvidenceError{Method: assistantStreamMethod, Path: "$", Detail: "流结束前没有完成的模型回复"} } - var total Usage - for _, usage := range d.usage { - total.InputTokens += usage.InputTokens - total.CachedTokens += usage.CachedTokens - total.OutputTokens += usage.OutputTokens - total.TotalTokens += usage.TotalTokens - } var events []Event switch { case d.block: @@ -1224,12 +1334,7 @@ func (d *AssistantStreamDecoder) End() ([]Event, error) { case !d.called && d.held != "": events = []Event{{Kind: EventText, Text: d.held}} } - if d.grounding != nil { - events = append(events, Event{Kind: EventGrounding, Grounding: d.grounding}) - } - if len(d.usage) > 0 { - events = append(events, Event{Kind: EventUsage, Usage: &total}) - } + events = append(events, d.recorded()...) finish := decodeFinishReason(1) if d.malformed { finish = decodeFinishReason(10) diff --git a/internal/aistudio/assistant_session.go b/internal/aistudio/assistant_session.go index 98e446ac..e3a284c2 100644 --- a/internal/aistudio/assistant_session.go +++ b/internal/aistudio/assistant_session.go @@ -20,6 +20,8 @@ const ( assistantAppletKind = "assistant-applet" // assistantAppletIdle 是写代码助手应用闲置后被巡检删除的时长 assistantAppletIdle = time.Hour + // assistantAppletDeleteAttempts 是应用服务以 HTTP 500 拒绝删除写代码助手应用时的最多尝试次数 + assistantAppletDeleteAttempts = 10 ) // assistantLink 是对话中一组连续的模型轮或其余轮次结束处的累积摘要 @@ -41,10 +43,10 @@ type assistantSession struct { applet string // key 是可继续时在 tips 中的键 key assistantSessionKey - // transcribed 表示应用中有以 user 轮转录的模型历史轮 - transcribed bool - busy bool - usedAt time.Time + // history 是新建应用时转录在自定义指令中的开头分组数 + history int + busy bool + usedAt time.Time } // assistantSessions 是本进程创建的写代码助手应用与其中可继续的对话 @@ -116,16 +118,18 @@ func canonicalJSON(raw json.RawMessage) string { return string(encoded) } -// findLocked 在 links 中从后往前找到之后还有新轮次、且本进程保存过的模型回复,返回所在会话、键与新轮次的开头 +// findLocked 在 links 最后一组之前最近的模型轮是本进程保存过的回复时,返回所在会话、键与之后新轮次的开头 func (store *assistantSessions) findLocked(apiKey string, links []assistantLink) (*assistantSession, assistantSessionKey, int) { - for index := len(links) - 2; index >= 0; index-- { - if !links[index].model { - continue - } - key := assistantSessionKey{apiKey: apiKey, digest: links[index].digest} - if session := store.tips[key]; session != nil { - return session, key, links[index].end - } + index := len(links) - 2 + if index >= 0 && !links[index].model { + index-- + } + if index < 0 { + return nil, assistantSessionKey{}, 0 + } + key := assistantSessionKey{apiKey: apiKey, digest: links[index].digest} + if session := store.tips[key]; session != nil { + return session, key, links[index].end } return nil, assistantSessionKey{}, 0 } @@ -157,17 +161,17 @@ func (store *assistantSessions) claim(apiKey string, accountID string, links []a return session, start } -// open 记录新建的应用,会话为进行中 -func (store *assistantSessions) open(accountID string, applet string) *assistantSession { +// open 记录新建的应用与转录在自定义指令中的开头分组数,会话为进行中 +func (store *assistantSessions) open(accountID string, applet string, history int) *assistantSession { store.mu.Lock() defer store.mu.Unlock() - session := &assistantSession{accountID: accountID, applet: applet, busy: true} + session := &assistantSession{accountID: accountID, applet: applet, history: history, busy: true} store.applets[applet] = session return session } // finish 结束会话上的请求:key 不为空时保存为可继续,否则放弃会话,应用留待巡检删除 -func (store *assistantSessions) finish(session *assistantSession, key *assistantSessionKey, transcribed bool) { +func (store *assistantSessions) finish(session *assistantSession, key *assistantSessionKey) { store.mu.Lock() defer store.mu.Unlock() session.busy = false @@ -176,7 +180,6 @@ func (store *assistantSessions) finish(session *assistantSession, key *assistant return } session.key = *key - session.transcribed = transcribed session.usedAt = time.Now() store.tips[*key] = session } @@ -192,14 +195,30 @@ func (store *assistantSessions) expire(applets []string, now time.Time) []string continue } if session != nil { - delete(store.tips, session.key) - delete(store.applets, applet) + store.dropLocked(session) } idle = append(idle, applet) } return idle } +// forget 放弃应用上的会话 +func (store *assistantSessions) forget(applet string) { + store.mu.Lock() + defer store.mu.Unlock() + if session := store.applets[applet]; session != nil { + store.dropLocked(session) + } +} + +// dropLocked 移除会话的应用记录与仍指向该会话的可继续位置 +func (store *assistantSessions) dropLocked(session *assistantSession) { + if store.tips[session.key] == session { + delete(store.tips, session.key) + } + delete(store.applets, session.applet) +} + // AssistantService 是可以查询写代码助手对话所在账户的生成服务 type AssistantService interface { AssistantAccount(context.Context, GenerateRequest) string @@ -233,17 +252,56 @@ func (s *PooledService) ReclaimIdleAssistantApplets(ctx context.Context) error { return reclaimErr } -// deleteAssistantApplet 删除写代码助手应用并解除账户绑定,应用已不存在时同样解除绑定 -func (s *PooledService) deleteAssistantApplet(ctx context.Context, accountID string, applet string) error { - body, _ := json.Marshal([]any{applet}) - response, err := s.client.do(ctx, "DeleteApplet", accountID, "", body, false) - if err == nil { - _, err = io.Copy(io.Discard, response.Body) - err = errors.Join(err, response.Body.Close()) +// DeleteAccountAssistantApplets 等账户上进行中的请求结束后独占账户,删除账户记录的全部写代码助手应用并解除绑定 +func (s *PooledService) DeleteAccountAssistantApplets(ctx context.Context, accountID string) error { + if len(s.pool.resourcesOfKind(assistantAppletKind)[accountID]) == 0 { + return nil + } + lease, err := s.pool.AcquireAccount(ctx, accountID) + if err != nil { + return err } - var rpcErr *RPCError - if err != nil && !(errors.As(err, &rpcErr) && rpcErr.StatusCode == http.StatusNotFound) { - return fmt.Errorf("删除写代码助手应用 %s: %w", applet, err) + leaseCtx := ContextWithAccountLease(ctx, lease) + var deleteErr error + for _, applet := range s.pool.resourcesOfKind(assistantAppletKind)[accountID] { + deleteErr = errors.Join(deleteErr, s.deleteAssistantApplet(leaseCtx, accountID, applet)) } + return errors.Join(deleteErr, lease.Release()) +} + +// deleteAssistantApplet 删除写代码助手应用,放弃其上的会话并解除账户绑定 +func (s *PooledService) deleteAssistantApplet(ctx context.Context, accountID string, applet string) error { + if err := s.client.deleteAssistantApplet(ctx, accountID, applet); err != nil { + return err + } + s.client.assistants.forget(applet) return s.pool.UnbindResource(applet) } + +// deleteAssistantApplet 经应用服务删除写代码助手应用,应用已不存在时同样返回 nil;生成刚结束的几秒内应用服务以 HTTP 500 拒绝删除,期间每秒重试 +func (c *Client) deleteAssistantApplet(ctx context.Context, accountID string, applet string) error { + body, _ := json.Marshal([]any{applet}) + for attempt := 1; ; attempt++ { + rpc := newRPCRequest("DeleteApplet", accountID, "", body, false) + rpc.URL = appletServiceRPCBase + rpc.Method + response, err := c.send(ctx, rpc) + if err == nil { + _, err = io.Copy(io.Discard, response.Body) + err = errors.Join(err, response.Body.Close()) + } + var rpcErr *RPCError + if err == nil || errors.As(err, &rpcErr) && rpcErr.StatusCode == http.StatusNotFound { + return nil + } + if rpcErr == nil || rpcErr.StatusCode != http.StatusInternalServerError || attempt == assistantAppletDeleteAttempts { + return fmt.Errorf("删除写代码助手应用 %s: %w", applet, err) + } + timer := time.NewTimer(time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return fmt.Errorf("删除写代码助手应用 %s: %w", applet, errors.Join(err, ctx.Err())) + case <-timer.C: + } + } +} diff --git a/internal/aistudio/client.go b/internal/aistudio/client.go index 2b62d887..3d3037d2 100644 --- a/internal/aistudio/client.go +++ b/internal/aistudio/client.go @@ -18,6 +18,8 @@ import ( const ( // MakerSuiteRPCBase 是 AI Studio RPC 根地址 MakerSuiteRPCBase = "https://alkalimakersuite-pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.MakerSuiteService/" + // appletServiceRPCBase 是 AI Studio 应用服务 RPC 根地址 + appletServiceRPCBase = "https://alkalimakersuiteapplets.pa.clients6.google.com/$rpc/google.internal.alkali.applications.makersuite.v1.applets.AppletService/" // JSONProtobufContentType 是 MakerSuite 使用的数组协议媒体类型 JSONProtobufContentType = "application/json+protobuf" ) @@ -154,13 +156,17 @@ func (e *RPCError) RetryAt(now time.Time) time.Time { } func (c *Client) do(ctx context.Context, method string, accountID string, requestID string, body []byte, streaming bool) (*RPCResponse, error) { - rpc := newRPCRequest(method, accountID, requestID, body, streaming) - c.applyBenefitTier(method, accountID, rpc.Header) + return c.send(ctx, newRPCRequest(method, accountID, requestID, body, streaming)) +} + +// send 发送 RPC 请求并校验响应状态与媒体类型 +func (c *Client) send(ctx context.Context, rpc RPCRequest) (*RPCResponse, error) { + c.applyBenefitTier(rpc.Method, rpc.AccountID, rpc.Header) response, err := c.transport.Do(ctx, rpc) if err != nil { - return nil, fmt.Errorf("发送 AI Studio %s: %w", method, err) + return nil, fmt.Errorf("发送 AI Studio %s: %w", rpc.Method, err) } - return validateRPCResponse(method, response) + return validateRPCResponse(rpc.Method, response) } func (c *Client) doProtected(ctx context.Context, request GenerateRequest, body []byte) (*RPCResponse, error) { diff --git a/internal/app/admin.go b/internal/app/admin.go index ea53bfa8..29ef2de2 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -522,11 +522,14 @@ func (admin *runtimeAdmin) UpdateAccount(ctx context.Context, accountID string, return admin.account(account.ID) } -func (admin *runtimeAdmin) DeleteAccount(_ context.Context, accountID string) error { +func (admin *runtimeAdmin) DeleteAccount(ctx context.Context, accountID string) error { account, err := admin.account(accountID) if err != nil { return err } + if err := admin.service.catalog.DeleteAccountAssistantApplets(ctx, accountID); err != nil { + admin.requests.log("auth", "WARN", fmt.Sprintf("账户写代码助手应用删除失败 | 账户=%s | 原因: %v", account.Label, err)) + } defer admin.syncModelCache() err = admin.service.changeModels(func() error { _, removeErr := admin.pool.Remove(accountID, func(account *aistudio.Account) error { diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 3396ef4f..a7eb2b4b 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -184,6 +184,7 @@ type modelCatalogService interface { RefreshAccountState(context.Context, string, bool) (aistudio.AccountRefresh, error) ReclaimExpiredResources(context.Context) error ReclaimIdleAssistantApplets(context.Context) error + DeleteAccountAssistantApplets(context.Context, string) error CachedModels() []aistudio.Model }