From b6a83ed00e7e04dc92f14dccc674a48218eb2ad6 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 25 Oct 2026 21:53:24 +0800 Subject: [PATCH 01/19] Show the Feishu process as one timeline under a colored header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card had lost its colored status header, and its process was split into separate thinking, commentary and tool-call sections, so following what the Agent did meant opening three panels. Cards again carry a colored header, and the process is one chronological timeline: commentary as plain text, thinking folded behind its first sentence, and each tool call showing what ran with its duration, failure or 进行中 while its result folds away. A running card shows the latest four steps and folds the earlier ones; a finished card puts the answer first and folds the whole process; a failed card shows the failed step under its cause. The oldest steps leave the card first to stay within Feishu's size limit. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 2 +- example/parsar/README.zh.md | 4 +- example/parsar/server/feishu.mjs | 134 ++++++++++++++++++--------- example/parsar/tests/feishu.test.mjs | 116 ++++++++++++----------- 4 files changed, 158 insertions(+), 98 deletions(-) diff --git a/example/parsar/README.md b/example/parsar/README.md index b9a1623ae..e2d6d2c87 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -123,7 +123,7 @@ Optional business questions use the declared `ask_user` function. They appear in 4. Text, rich posts, images, files, audio and video, quoted messages and merged forwards are normalized before dispatch. Attachment limits are eight resources and 5 MiB total. Follow-up files wait until the conversation is idle. Quoted messages are read up to four levels; earlier group messages are not read. 5. `/cancel`, `/answer `, `/new` and `/help` work in the same thread. A plain reply answers a single pending business question. Cancellation takes priority over queued follow-ups and recovers an uncertain creation before cancelling it. -Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. Available reasoning summaries, progress commentary and tool calls use separate collapsed sections. Tool calls pair with their results and show parameters, output, duration and failure details. The card previews the latest six calls with explicitly marked, byte-bounded excerpts; it counts each call once and does not count failed or incomplete calls as completed. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. +Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. The card has a colored header for its status. Its process is one timeline in the order things happened: commentary appears as plain text, thinking folds behind its first sentence, and each tool call shows what ran with its duration, failure or 进行中 while its result (parameters, output, error, exit code) folds away. A running card shows the latest four steps and folds the earlier ones; a finished card puts the answer first and folds the whole process into **处理过程**; a failed card shows the failed step right under its cause. Long content is cut to marked, byte-bounded excerpts, and the oldest steps leave the card first to stay within Feishu's card size limit. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. ### Bot identity diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index 8c7a64046..e568b32f5 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: 012de696eb00f1550dfa2651d517821f7831e01ad3e6039b5038555a68ef75b7 +source_hash: f400d9bd09523a6bf3a0c5c1a860562bf2d97a98712c1bb11c50be3f46ca0076 --- # Parsar 工作空间 @@ -128,7 +128,7 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 5 MiB。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 5. `/cancel`、`/answer <回答>`、`/new` 和 `/help` 在同一话题中使用。只有一个待回答的业务问题时,直接回复即可作答。取消优先于排队中的追问,并会先确认不确定的创建再取消。 -收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。已有的思考摘要、过程说明和工具调用分别默认折叠。工具调用与返回结果合并,展开可查看参数、输出、耗时和失败详情。卡片预览最近六次调用,长内容按字节限制截取并明确标注;一次调用只计数一次,失败或未完成不算完成。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 +收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。卡片顶部是带颜色的状态标题栏。处理过程按发生顺序排成一条时间线:说明直接显示文字;思考折叠在第一句后面;每次工具调用直接显示运行了什么,以及用时、失败或“进行中”,返回结果(参数、输出、错误、退出码)折叠起来。执行中的卡片显示最近四步,更早的步骤折叠;完成后回复放在最前面,整个过程折叠成 **处理过程**;失败时出错的那一步紧跟在失败原因下面。长内容按字节限制截取并明确标注;为了不超过飞书卡片的大小限制,最早的步骤会先移出卡片。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 ### 机器人身份 {#bot-identity} diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index fd709ee22..4024f624d 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -199,9 +199,9 @@ function panel({ title, content, elements }) { vertical_spacing: "8px", padding: "0px 0px 8px 0px", header: { - title: { tag: "markdown", content: grey(title) }, + title: { tag: "markdown", content: title }, width: "fill", - padding: "8px 0px 8px 0px", + padding: "6px 0px 6px 0px", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px" }, icon_position: "right", icon_expanded_angle: -180, @@ -210,20 +210,21 @@ function panel({ title, content, elements }) { }; } -// One restrained status line replaces the large colored banner. -function statusCard(status, body, panels = []) { - const [title, color] = cardTones[status] || cardTones.failed; +// Feishu rejects cards above about 30 KB of JSON. +const cardBudget = 28000; +const bytes = (value) => Buffer.byteLength(JSON.stringify(value)); + +function statusCard(status, body, elements = []) { + const [title, template] = cardTones[status] || cardTones.failed; return JSON.stringify({ schema: "2.0", config: { update_multi: true, summary: { content: title } }, + // The colored header carries the status, so the body starts with the content. + header: { title: { tag: "plain_text", content: title }, template }, body: { - padding: "16px 16px 12px 16px", + padding: "12px 16px 12px 16px", vertical_spacing: "8px", - elements: [ - { tag: "markdown", text_size: "notation", content: `${title}` }, - { tag: "markdown", content: excerpt(body, status === "requires_action" ? 8000 : 12000) }, - ...(panels.length ? [{ tag: "hr" }, ...panels.map(panel)] : []), - ], + elements: [{ tag: "markdown", content: body }, ...elements], }, }); } @@ -258,7 +259,7 @@ function step(item) { if (item.type === "command_execution" || typeof item.command === "string") { const command = String(item.command || "").replace(/\s+/g, " ").trim() .replace(/^\/bin\/(?:ba|z)?sh -lc (['"])([\s\S]*)\1$/, "$2"); - return ["运行命令", command.length > 56 ? `${command.slice(0, 55)}…` : command]; + return ["运行", command]; } if (item.type === "mcp_call") return ["调用工具", [item.server_label, item.name].filter(Boolean).join(" · ")]; if (item.type === "function_call") return ["调用工具", item.name || ""]; @@ -267,21 +268,57 @@ function step(item) { return ["执行步骤", item.name || item.type || ""]; } -function stepPanel(item) { +const clip = (value, limit) => { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; +}; +// Markdown and Feishu tags stay literal inside a one-line title. +const literal = (value) => value.replace(/[<>]/g, "").replaceAll("`", "'"); + +// One tool call: what ran stays visible; its result folds away. +function toolRow(item) { const [label, detail] = step(item); - const state = { completed: "完成", failed: "失败", incomplete: "未完成", in_progress: "进行中" }[item.status] || "状态未知"; - const duration = typeof item.duration_ms === "number" ? ` · ${(item.duration_ms / 1000).toFixed(1)}s` : ""; + const state = item.status === "failed" + ? "失败" + : item.status === "in_progress" + ? grey("进行中") + : item.status === "incomplete" + ? grey("未完成") + : typeof item.duration_ms === "number" + ? grey(`${(item.duration_ms / 1000).toFixed(1)}s`) + : ""; + const title = [grey(label), detail ? `\`${literal(clip(detail, 200))}\`` : "", state].filter(Boolean).join(" "); const fields = [ - ["命令", item.command], ["工作目录", item.cwd], ["参数", item.arguments ?? item.action], + ["参数", item.command ? undefined : item.arguments ?? item.action], ["输出", item.output], ["错误", item.error], ["退出码", item.exit_code], ].filter(([, value]) => value !== undefined && value !== null && value !== ""); + if (!fields.length) return { tag: "markdown", content: title }; // Plain text keeps tool output literal, including code fences and mention markup. return panel({ - title: `${state} · ${label}${detail ? ` · ${excerpt(detail, 180).replace(/[<>]/g, "")}` : ""}${duration}`, - elements: [{ tag: "div", text: { tag: "plain_text", content: excerpt(fields.map(([name, value]) => `${name}\n${excerpt(value, 700)}`).join("\n\n") || "暂无详细输出。", 1000) } }], + title, + elements: [{ tag: "div", text: { tag: "plain_text", content: excerpt(fields.map(([name, value]) => `${name}\n${excerpt(value, 1200)}`).join("\n\n"), 1600) } }], }); } +// Thinking folds behind its first sentence; commentary is short and shown as is. +function timelineRow(entry) { + if (entry.type === "say") return { tag: "markdown", content: excerpt(entry.text, 800) }; + if (entry.type === "think") + return panel({ title: `${grey("思考")} ${literal(clip(entry.text.split("\n").find((line) => line.trim()) || "", 40))}`, content: excerpt(entry.text, 1500) }); + return toolRow(entry.item); +} + +// The oldest rows leave the card first when it would grow past Feishu's limit. +function fitRows(rows, budget) { + const kept = [...rows]; + let dropped = 0; + while (kept.length > 1 && bytes(kept) > budget) { + kept.shift(); + dropped++; + } + return dropped ? [{ tag: "markdown", content: grey(`更早的 ${dropped} 步见网页对话`) }, ...kept] : kept; +} + // A tool-result answer resumes the same Core Turn. Display only the exchange // after that answer, not every earlier question and command in the Turn. function exchangeItems(items) { @@ -290,6 +327,8 @@ function exchangeItems(items) { return items.slice(boundary + 1); } +const recentRows = 4; + export function formatTurnCard(status, items = [], error, turn, questions = []) { items = exchangeItems(items); const text = (item) => (item.content || []).filter((p) => p.type === "output_text").map((p) => (p.text || "").trim()).filter(Boolean).join("\n"); @@ -301,45 +340,54 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) const authorization = messages.filter((item) => qrImages(text(item)).length); const authorizationText = authorization.map((item) => readable(text(item))).join("\n\n"); const answer = latest?.phase === "commentary" || authorization.includes(latest) ? "" : latest ? readable(text(latest)) : ""; - const reasoning = items.filter((x) => x.type === "reasoning").flatMap((x) => x.summary || x.content || []) - .map((p) => typeof p === "string" ? p : p?.text || "").filter(Boolean).join("\n\n"); - const commentary = messages.filter((x) => !authorization.includes(x) && (status === "in_progress" || x !== latest || x.phase === "commentary")).map(text).filter(Boolean).join("\n\n"); const outputs = new Map(items.filter((x) => x.type === "function_call_output").map((x) => [x.call_id, x])); - const steps = items.filter((x) => !["message", "agent_message", "reasoning", "function_call_output"].includes(x.type) && !(x.type === "function_call" && x.name === "ask_user")) - .map((x) => { - const output = x.type === "function_call" ? outputs.get(x.call_id) : undefined; - return output ? { ...x, status: output.status, output: output.output, error: output.error } : x; - }); - const panels = []; - if (reasoning) panels.push({ title: "思考摘要", content: excerpt(reasoning, 3000) }); - if (commentary) panels.push({ title: "过程说明", content: excerpt(commentary, 1500) }); - if (steps.length) panels.push({ - title: `工具调用 · ${steps.length} 次`, - elements: [ - ...(steps.length > 6 ? [{ tag: "markdown", content: grey(`展示最近 6 次,共 ${steps.length} 次;完整记录保留在网页会话。`) }] : []), - ...steps.slice(-6).map(stepPanel), - ], + // Thinking, commentary and tool calls in the order they happened. + const entries = items.flatMap((x) => { + if (x.type === "reasoning") { + const thought = (x.summary || x.content || []).map((p) => typeof p === "string" ? p : p?.text || "").filter(Boolean).join("\n\n").trim(); + return thought ? [{ type: "think", text: thought }] : []; + } + if (x.type === "message") { + const said = x.role === "assistant" && !authorization.includes(x) && (status === "in_progress" || x !== latest || x.phase === "commentary") ? readable(text(x)) : ""; + return said ? [{ type: "say", text: said }] : []; + } + if (["agent_message", "function_call_output"].includes(x.type) || (x.type === "function_call" && x.name === "ask_user")) return []; + const output = x.type === "function_call" ? outputs.get(x.call_id) : undefined; + return [{ type: "tool", item: output ? { ...x, status: output.status, output: output.output, error: output.error } : x }]; }); + const rows = entries.map(timelineRow); + const tools = entries.filter((entry) => entry.type === "tool").map((entry) => entry.item); if (status === "in_progress") { const last = items.filter((x) => x.role !== "user" && x.type !== "function_call_output").at(-1); const current = !last ? waitingHeadline(turn) : last.type === "message" ? "正在整理回复" : last.type === "reasoning" ? "正在思考" : last.status === "in_progress" ? `正在${step(last)[0]}` : "正在整理结果"; - const done = steps.filter((x) => x.status === "completed").length; - const failed = steps.filter((x) => x.status === "failed").length; - const count = steps.length ? `工具调用 ${steps.length} 次 · 完成 ${done} 次${failed ? ` · 失败 ${failed} 次` : ""}` : ""; const typical = !last && turn?.expected_s && turn.stage !== "retrying" ? typicalWait.replace("{{value0}}", turn.expected_s) : ""; - return statusCard(status, [`**${current}**`, grey([count, typical].filter(Boolean).join(" · ")), authorizationText].filter(Boolean).join("\n"), panels); + const progress = rows.length ? `${rows.length} 步` : typical; + const body = excerpt([`**${current}**${progress ? ` ${grey(`· ${progress}`)}` : ""}`, authorizationText].filter(Boolean).join("\n"), 8000); + const earlier = rows.slice(0, -recentRows), recent = rows.slice(-recentRows); + const budget = cardBudget - bytes(body); + const shown = fitRows(recent, budget); + const fold = earlier.length ? [panel({ title: grey(`更早的 ${earlier.length} 步`), elements: fitRows(earlier, budget - bytes(shown)) })] : []; + return statusCard(status, body, [...fold, ...shown]); } const context = questions.some((action) => questionText(action, false).trim() === answer.trim()) ? "" : answer; // Put the decision first; keep its explanation available without filling the chat. // Authorization instructions stay visible alongside the separately sent QR. const foldContext = status === "requires_action" && questions.length && context; - if (foldContext) panels.unshift({ title: "补充说明", content: excerpt(context, 4000) }); - const body = status === "requires_action" + const body = excerpt(status === "requires_action" ? [...questions.map((action) => questionText(action)), authorizationText, foldContext ? "" : context, grey("直接回复这条消息即可继续。")].filter(Boolean).join("\n\n") : status === "completed" ? [authorizationText, answer].filter(Boolean).join("\n\n") || "已完成处理。" : status === "cancelled" ? [authorizationText, answer].filter(Boolean).join("\n\n") || "已按你的要求停止。" - : [authorizationText, answer || "这次没有完成。", error ? `${error}` : "", grey("直接回复即可重试。")].filter(Boolean).join("\n\n"); - return statusCard(status, body, panels); + : [authorizationText, answer || "这次没有完成。", error ? `${error}` : "", grey("直接回复即可重试。")].filter(Boolean).join("\n\n"), status === "requires_action" ? 8000 : 12000); + const elements = []; + if (foldContext) elements.push(panel({ title: grey("补充说明"), content: excerpt(context, 4000) })); + // A failure shows the step that failed right under its cause. + const failedTool = status === "failed" ? tools.findLast((item) => item.status === "failed") : undefined; + if (failedTool) elements.push(toolRow(failedTool)); + if (rows.length) { + const budget = cardBudget - bytes(body) - bytes(elements); + elements.push(panel({ title: grey(`处理过程 · ${rows.length} 步`), elements: fitRows(rows, budget) })); + } + return statusCard(status, body, elements.length ? [{ tag: "hr" }, ...elements] : []); } export async function createFeishuTransport(settings, receive) { diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index f5b68ef78..1cc3200c1 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -1077,20 +1077,15 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t const firstCard = JSON.parse(f.sent[0].data.content); assert.equal(firstCard.schema, "2.0"); assert.equal(firstCard.config.summary.content, "处理中"); - assert.equal(firstCard.header, undefined); - const [, current, , thinkingPanel, stepsPanel] = firstCard.body.elements; + assert.deepEqual(firstCard.header, { title: { tag: "plain_text", content: "处理中" }, template: "blue" }); + // One chronological timeline: commentary as text, the command visible. + const [current, said, ran] = firstCard.body.elements; assert.match(current.content, /正在整理结果/); - assert.match(current.content, /工具调用 1 次 · 完成 1 次/); - assert.doesNotMatch(current.content, /先读取飞书技能文档/); - assert.equal(thinkingPanel.tag, "collapsible_panel"); - assert.equal(thinkingPanel.expanded, false); - assert.match(thinkingPanel.header.title.content, /过程说明/); - assert.match(thinkingPanel.elements[0].content, /先读取飞书技能文档/); - assert.match(stepsPanel.header.title.content, /工具调用 · 1 次/); - assert.match( - stepsPanel.elements[0].elements[0].text.content, - /cat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md/, - ); + assert.match(current.content, /2 步/); + assert.equal(said.tag, "markdown"); + assert.equal(said.content, "先读取飞书技能文档。"); + assert.equal(ran.tag, "markdown", "a command without output has nothing to fold"); + assert.match(ran.content, /运行.*`cat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md`.*0\.4s/); finishTurn = true; items.push({ @@ -1105,11 +1100,16 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(patches.length, 1, "must patch the existing card in place"); const patchedCard = JSON.parse(patches[0].data.content); assert.equal(patchedCard.config.summary.content, "已完成"); - assert.equal(patchedCard.header, undefined); - const [, answer, , thoughts, record] = patchedCard.body.elements; + assert.deepEqual(patchedCard.header, { title: { tag: "plain_text", content: "已完成" }, template: "green" }); + // The answer first; the whole process folds into one row. + const [answer, rule, process] = patchedCard.body.elements; assert.equal(answer.content, "你好!我是飞书工作助手。"); - assert.match(thoughts.elements[0].content, /先读取飞书技能文档/); - assert.match(record.header.title.content, /工具调用 · 1 次/); + assert.equal(rule.tag, "hr"); + assert.equal(process.tag, "collapsible_panel"); + assert.equal(process.expanded, false); + assert.match(process.header.title.content, /处理过程 · 2 步/); + assert.equal(process.elements[0].content, "先读取飞书技能文档。"); + assert.match(process.elements[1].content, /`cat \/workspace/); assert.doesNotMatch( JSON.stringify(patchedCard), /任务编号|在 Web 会话下载|Parsar/, @@ -1378,7 +1378,7 @@ test("Feishu private informational and rejected commands retain the active conve ); }); -test("failure remains visible without a banner and empty detail panels are omitted", async () => { +test("a failure shows its cause with the failed step right below, and a plain answer adds no rows", async () => { const { formatTurnCard, feishuCard } = await import("../server/feishu.mjs"); const card = JSON.parse( formatTurnCard( @@ -1394,11 +1394,12 @@ test("failure remains visible without a banner and empty detail panels are omitt "permission denied", ), ); - assert.equal(card.header, undefined); + assert.deepEqual(card.header, { title: { tag: "plain_text", content: "执行失败" }, template: "red" }); assert.equal(card.config.summary.content, "执行失败"); - assert.match(card.body.elements[0].content, /color='red'/); - assert.match(card.body.elements[1].content, /permission denied<\/font>/); - assert.match(card.body.elements[3].elements[0].header.title.content, /失败 · 运行命令/); + const [cause, , failed, process] = card.body.elements; + assert.match(cause.content, /permission denied<\/font>/); + assert.match(failed.content, /运行.*`lark-cli docs \+fetch --doc abc`.*失败<\/font>/); + assert.match(process.header.title.content, /处理过程 · 1 步/); const quiet = JSON.parse( formatTurnCard("completed", [ { type: "message", role: "assistant", content: [{ type: "output_text", text: "答案" }] }, @@ -1406,13 +1407,13 @@ test("failure remains visible without a banner and empty detail panels are omitt ); assert.deepEqual( quiet.body.elements.map((element) => element.tag), - ["markdown", "markdown"], + ["markdown"], ); - assert.equal(quiet.body.elements[1].content, "答案"); + assert.equal(quiet.body.elements[0].content, "答案"); const plain = feishuCard("已请求取消,等待 Agent 确认停止。"); assert.equal(plain.header, undefined); assert.equal(plain.body.elements[0].content, "已请求取消,等待 Agent 确认停止。"); - assert.match(feishuCard("执行失败\n\n环境准备失败").body.elements[0].content, /color='red'/); + assert.equal(feishuCard("执行失败\n\n环境准备失败").header.template, "red"); }); test("web searches appear as readable steps in the Feishu record", async () => { @@ -1427,10 +1428,8 @@ test("web searches appear as readable steps in the Feishu record", async () => { { type: "message", role: "assistant", content: [{ type: "output_text", text: "Done" }] }, ]), ); - assert.match( - card.body.elements[3].elements[0].header.title.content, - /完成 · 搜索网页 · OpenAgentCore release/, - ); + const search = card.body.elements[2].elements[0]; + assert.match(search.header.title.content, /搜索网页.*`OpenAgentCore release`/); }); test("Feishu delivers QR during authorization and artifact media once, retaining uploaded keys after a lost send", async (t) => { @@ -1557,7 +1556,7 @@ test("Feishu SDK uploads multipart bytes and preserves definite rejection and re await assert.rejects(disconnected.upload(Buffer.from("file"), "file.txt"), (error) => error.status === 503 && !error.message.includes("private-network-data")); }); -test("card details join tool results, retain useful diagnostics and count calls once", async () => { +test("the timeline joins tool results, keeps commands visible and folds their diagnostics", async () => { const { formatTurnCard } = await import("../server/feishu.mjs"); const card = JSON.parse(formatTurnCard("in_progress", [ { type: "reasoning", summary: [{ text: "先检查接口,再处理结果。" }] }, @@ -1566,20 +1565,19 @@ test("card details join tool results, retain useful diagnostics and count calls { type: "command_execution", command: "pwd", cwd: "/workspace", output: "/workspace", exit_code: 0, duration_ms: 420, status: "completed" }, { type: "mcp_call", name: "read", server_label: "docs", arguments: { id: 3 }, status: "incomplete" }, ])); - assert.match(card.body.elements[1].content, /工具调用 3 次 · 完成 1 次 · 失败 1 次/); - const panels = card.body.elements.filter((x) => x.tag === "collapsible_panel"); - assert.equal(panels[0].header.title.content.includes("思考摘要"), true); - const tools = panels.at(-1); - assert.match(tools.header.title.content, /3 次/); - assert.match(tools.elements[0].header.title.content, /失败/); - const literal = tools.elements[0].elements[0].text; + const [current, thought, lookup, pwd, read] = card.body.elements; + assert.match(current.content, /4 步/); + assert.match(thought.header.title.content, /思考.*先检查接口/); + assert.equal(thought.elements[0].content, "先检查接口,再处理结果。"); + assert.match(lookup.header.title.content, /调用工具.*`lookup`.*失败<\/font>/); + const literal = lookup.elements[0].text; assert.equal(literal.tag, "plain_text"); assert.match(literal.content, /invoice/); assert.match(literal.content, /permission denied/); assert.match(literal.content, //); - assert.match(tools.elements[1].elements[0].text.content, /退出码\n0/); - assert.match(tools.elements[1].header.title.content, /0.4s/); - assert.match(tools.elements[2].header.title.content, /未完成/); + assert.match(pwd.header.title.content, /运行.*`pwd`.*0\.4s/); + assert.match(pwd.elements[0].text.content, /退出码\n0/); + assert.match(read.header.title.content, /调用工具.*`docs · read`.*未完成/); }); test("resumed questions display only the current exchange, including on completion", async () => { @@ -1594,9 +1592,9 @@ test("resumed questions display only the current exchange, including on completi { type: "function_call", call_id: "q2", name: "ask_user" }, ]; const card = JSON.parse(formatTurnCard("requires_action", items, undefined, undefined, [{ arguments: JSON.stringify({ question: "What should the bot do?", options: ["Documents", "Reports"] }) }])); - assert.match(card.body.elements[1].content, /^What should the bot do/); - assert.doesNotMatch(card.body.elements[1].content, /CURRENT CONTEXT/); - assert.match(card.body.elements[3].elements[0].content, /CURRENT CONTEXT/); + assert.match(card.body.elements[0].content, /^What should the bot do/); + assert.doesNotMatch(card.body.elements[0].content, /CURRENT CONTEXT/); + assert.match(card.body.elements[2].elements[0].content, /CURRENT CONTEXT/); assert.doesNotMatch(JSON.stringify(card), /OLD INVENTORY|old command|function_call_output|工具调用/); items.push({ type: "function_call_output", call_id: "q2", output: "Reports" }, message("DONE")); assert.doesNotMatch(formatTurnCard("completed", items), /CURRENT CONTEXT|OLD INVENTORY/); @@ -1688,10 +1686,10 @@ test("large multilingual diagnostics stay below the Feishu card payload limit", ...Array.from({ length: 20 }, (_, i) => ({ type: "mcp_call", name: `tool${i}`, status: "completed", arguments: { input: large }, output: large })), { type: "message", role: "assistant", content: [{ type: "output_text", text: large }] }, ]; - for (const status of ["completed", "requires_action"]) { + for (const status of ["in_progress", "completed", "requires_action"]) { const payload = formatTurnCard(status, items, undefined, undefined, [{ arguments: { question: large, options: [large] } }]); assert.ok(Buffer.byteLength(payload) < 30000, `${status}: ${Buffer.byteLength(payload)}`); - assert.match(payload, /展示最近 6 次/); + assert.match(payload, /更早的 \d+ 步见网页对话/, "the oldest rows leave the card first"); assert.match(payload, /内容较长,已截取/); } }); @@ -1731,10 +1729,10 @@ test("identical question text is shown once and agent messages do not count as t { type: "agent_message", content: "subagent progress" }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "Which bot?" }] }, ], undefined, undefined, [{ arguments: { question: "Which bot?", options: ["Writer", "Analyst"] } }])); - assert.equal(card.body.elements[1].content.split("Which bot?").length - 1, 1); - assert.match(card.body.elements[1].content, /Writer/); - assert.match(card.body.elements.at(-1).header.title.content, /工具调用 · 1 次/); - assert.doesNotMatch(JSON.stringify(card), /agent_message|状态未知/); + assert.equal(card.body.elements[0].content.split("Which bot?").length - 1, 1); + assert.match(card.body.elements[0].content, /Writer/); + assert.match(card.body.elements.at(-1).header.title.content, /处理过程 · 1 步/); + assert.doesNotMatch(JSON.stringify(card), /agent_message|subagent progress/); }); @@ -1747,9 +1745,9 @@ test("authorization instructions stay visible across message phases and later re { type: "message", role: "assistant", phase, content: [{ type: "output_text", text: `请应用管理员扫码 [授权](https://open.feishu.cn/page/launcher?user_code=test)\n![QR](/app/feishu/provision/${id}/qrcode)` }] }, { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: "等待授权完成。" }] }, ], undefined, undefined, [{ arguments: { question: "授权完成后告诉我" } }])); - assert.match(card.body.elements[1].content, /请应用管理员扫码/); - assert.match(card.body.elements[1].content, /https:\/\/open.feishu.cn/); - assert.match(card.body.elements[1].content, /授权二维码见图片消息/); + assert.match(card.body.elements[0].content, /请应用管理员扫码/); + assert.match(card.body.elements[0].content, /https:\/\/open.feishu.cn/); + assert.match(card.body.elements[0].content, /授权二维码见图片消息/); assert.equal(JSON.stringify(card).split("user_code=test").length - 1, 1); } } @@ -1985,3 +1983,17 @@ test("completed-turn cursors release answered questions before the next turn", a await worker.tick(); assert.ok(patches.some((payload) => payload.includes("SECOND RESULT")) || f.sent.some((row) => row.data.content.includes("SECOND RESULT"))); }); + +test("a running card shows the latest four steps and folds the earlier ones", async () => { + const { formatTurnCard } = await import("../server/feishu.mjs"); + const items = Array.from({ length: 7 }, (_, i) => ({ type: "command_execution", command: `step ${i + 1}`, duration_ms: 1000, status: "completed" })); + items.push({ type: "command_execution", command: "step 8", status: "in_progress" }); + const [current, earlier, ...recent] = JSON.parse(formatTurnCard("in_progress", items)).body.elements; + assert.match(current.content, /正在运行.*8 步/); + assert.equal(earlier.tag, "collapsible_panel"); + assert.equal(earlier.expanded, false); + assert.match(earlier.header.title.content, /更早的 4 步/); + assert.deepEqual(earlier.elements.map((row) => row.content.match(/`(.*)`/)[1]), ["step 1", "step 2", "step 3", "step 4"]); + assert.deepEqual(recent.map((row) => row.content.match(/`(.*)`/)[1]), ["step 5", "step 6", "step 7", "step 8"]); + assert.match(recent.at(-1).content, /进行中/); +}); From 75b8a81103fcfea7bc6392f5e2a6142784866e6b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 25 Oct 2026 22:12:04 +0800 Subject: [PATCH 02/19] Fold Feishu tool calls into one row and add /clear and /stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only commentary now stays open in the card timeline: consecutive tool calls fold into one row with their total time, failures or 进行中, and each call folds again behind its command. Size fitting reaches into a folded row when that row alone is too large. Feishu commands are /clear, which stops running work and makes the next message start a new conversation (in a private chat or in the same group topic), /stop for the current task, /answer and /help. They replace /new, /reset and /cancel. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 6 +- example/parsar/README.zh.md | 8 +- example/parsar/server/feishu.mjs | 128 ++++++++++++++++++--------- example/parsar/tests/feishu.test.mjs | 72 ++++++++------- 4 files changed, 133 insertions(+), 81 deletions(-) diff --git a/example/parsar/README.md b/example/parsar/README.md index e2d6d2c87..ee75259c1 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -119,11 +119,11 @@ Optional business questions use the declared `ask_user` function. They appear in 1. On the card, choose the **Agent**, turn on **私聊** and/or **群聊**, then **保存并启用**. Enabling requires credentials, an Agent with an environment template and at least one of the two. With **群聊** on, the bot answers mentions in every group it has joined; adding the bot to a group is all that is needed. Who can add the bot is controlled by the app’s availability in the developer console. 2. In the developer console, enable the bot, long-connection event delivery and `im.message.receive_v1`, publish the app and add it to the target groups. Grant mention receipt and send-as-bot permissions; direct messages, message resources and reactions need their own scopes. A missing reaction permission does not discard a message. -3. Private chat keeps one conversation per sender until `/new` or `/reset`. In groups, mentioning the bot starts a topic and each topic is one conversation shared by everyone who replies in it. Follow-ups without a mention arrive only when the app has the sensitive “read all group messages” scope (`im:message.group_msg`); otherwise mention the bot again in the topic. A topic reply that mentions only other people is addressed to them and is ignored. The bot serves its own tenant only: messages from external contacts are ignored. Other bots and `@all` are ignored, as are groups while **群聊** is off and private chats while **私聊** is off. Changing the bound Agent affects new topics and new private conversations. +3. Private chat keeps one conversation per sender until `/clear`. In groups, mentioning the bot starts a topic and each topic is one conversation shared by everyone who replies in it. Follow-ups without a mention arrive only when the app has the sensitive “read all group messages” scope (`im:message.group_msg`); otherwise mention the bot again in the topic. A topic reply that mentions only other people is addressed to them and is ignored. The bot serves its own tenant only: messages from external contacts are ignored. Other bots and `@all` are ignored, as are groups while **群聊** is off and private chats while **私聊** is off. Changing the bound Agent affects new topics and new private conversations. 4. Text, rich posts, images, files, audio and video, quoted messages and merged forwards are normalized before dispatch. Attachment limits are eight resources and 5 MiB total. Follow-up files wait until the conversation is idle. Quoted messages are read up to four levels; earlier group messages are not read. -5. `/cancel`, `/answer `, `/new` and `/help` work in the same thread. A plain reply answers a single pending business question. Cancellation takes priority over queued follow-ups and recovers an uncertain creation before cancelling it. +5. Commands: `/clear` clears the context: it stops any running work and the next message starts a new conversation, in a private chat or in the same group topic. `/stop` stops the current task; it takes priority over queued follow-ups and recovers an uncertain creation before stopping it. `/answer ` answers a pending business question, and a plain reply answers when only one is pending. `/help` lists the commands. -Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. The card has a colored header for its status. Its process is one timeline in the order things happened: commentary appears as plain text, thinking folds behind its first sentence, and each tool call shows what ran with its duration, failure or 进行中 while its result (parameters, output, error, exit code) folds away. A running card shows the latest four steps and folds the earlier ones; a finished card puts the answer first and folds the whole process into **处理过程**; a failed card shows the failed step right under its cause. Long content is cut to marked, byte-bounded excerpts, and the oldest steps leave the card first to stay within Feishu's card size limit. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. +Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. The card has a colored header for its status. Its process is one timeline in the order things happened. Only commentary stays open as plain text; thinking folds behind its first sentence, and consecutive tool calls fold into one row (such as `运行 3 条命令` with their total time, failures or 进行中) in which each call folds again behind its command, showing parameters, output, error and exit code. A running card shows the latest four steps and folds the earlier ones; a finished card puts the answer first and folds the whole process into **处理过程**; a failed card shows the failed step right under its cause. Long content is cut to marked, byte-bounded excerpts, and the oldest steps leave the card first to stay within Feishu's card size limit. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. ### Bot identity diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index e568b32f5..dce57e365 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: f400d9bd09523a6bf3a0c5c1a860562bf2d97a98712c1bb11c50be3f46ca0076 +source_hash: e82aa7249febf32add4459dabd95f6814b2cade69d0bd5ab962cad2f12d10acb --- # Parsar 工作空间 @@ -124,11 +124,11 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 1. 在卡片上选择 **Agent**,打开 **私聊** 和/或 **群聊**,然后 **保存并启用**。启用需要应用凭证、已配置环境模板的 Agent,以及两者至少开启一项。打开 **群聊** 后,机器人会回复它所在的任何群里的 @ 消息,把机器人拉进群即可使用。谁能添加机器人由开发者后台中应用的可用范围控制。 2. 在开发者后台启用机器人、长连接事件推送和 `im.message.receive_v1`,发布应用并把它加入目标群。授予接收 @ 消息和以机器人身份发送消息的权限;私聊、消息资源和表情回复需要各自的权限。缺少表情权限不会丢弃消息。 -3. 私聊为每个发送者保持一个对话,直到发送 `/new` 或 `/reset`。群聊中 @ 机器人会开启一个话题,每个话题是一个对话,所有在其中回复的人共享。只有应用开通了敏感权限“获取群组中所有消息”(`im:message.group_msg`),话题中不带 @ 的后续消息才会推送给机器人;否则需要在话题中再次 @ 机器人。话题中只 @ 了其他人的回复是发给他们的,会被忽略。其他机器人和 机器人只服务自己所在的企业,外部联系人发来的消息会被忽略。`@所有人` 会被忽略;关闭 **群聊** 时忽略群消息,关闭 **私聊** 时忽略私聊。更换绑定的 Agent 只影响新话题和新的私聊对话。 +3. 私聊为每个发送者保持一个对话,直到发送 `/clear`。群聊中 @ 机器人会开启一个话题,每个话题是一个对话,所有在其中回复的人共享。只有应用开通了敏感权限“获取群组中所有消息”(`im:message.group_msg`),话题中不带 @ 的后续消息才会推送给机器人;否则需要在话题中再次 @ 机器人。话题中只 @ 了其他人的回复是发给他们的,会被忽略。其他机器人和 机器人只服务自己所在的企业,外部联系人发来的消息会被忽略。`@所有人` 会被忽略;关闭 **群聊** 时忽略群消息,关闭 **私聊** 时忽略私聊。更换绑定的 Agent 只影响新话题和新的私聊对话。 4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 5 MiB。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 -5. `/cancel`、`/answer <回答>`、`/new` 和 `/help` 在同一话题中使用。只有一个待回答的业务问题时,直接回复即可作答。取消优先于排队中的追问,并会先确认不确定的创建再取消。 +5. 命令:`/clear` 清空上下文:停止正在执行的任务,下一条消息开始新对话,私聊和群聊话题都适用(群聊仍在同一个话题里)。`/stop` 停止当前任务,优先于排队中的追问,并会先确认不确定的创建再停止。`/answer <回答>` 回答待处理的业务问题;只有一个待回答的问题时,直接回复即可。`/help` 列出这些命令。 -收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。卡片顶部是带颜色的状态标题栏。处理过程按发生顺序排成一条时间线:说明直接显示文字;思考折叠在第一句后面;每次工具调用直接显示运行了什么,以及用时、失败或“进行中”,返回结果(参数、输出、错误、退出码)折叠起来。执行中的卡片显示最近四步,更早的步骤折叠;完成后回复放在最前面,整个过程折叠成 **处理过程**;失败时出错的那一步紧跟在失败原因下面。长内容按字节限制截取并明确标注;为了不超过飞书卡片的大小限制,最早的步骤会先移出卡片。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 +收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。卡片顶部是带颜色的状态标题栏。处理过程按发生顺序排成一条时间线。只有说明直接显示文字;思考折叠在第一句后面;连续的工具调用折叠成一行(例如“运行 3 条命令”,附总用时、失败次数或“进行中”),展开后每次调用再折叠在它的命令后面,可查看参数、输出、错误和退出码。执行中的卡片显示最近四步,更早的步骤折叠;完成后回复放在最前面,整个过程折叠成 **处理过程**;失败时出错的那一步紧跟在失败原因下面。长内容按字节限制截取并明确标注;为了不超过飞书卡片的大小限制,最早的步骤会先移出卡片。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 ### 机器人身份 {#bot-identity} diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index 4024f624d..e5fbf6395 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -275,19 +275,21 @@ const clip = (value, limit) => { // Markdown and Feishu tags stay literal inside a one-line title. const literal = (value) => value.replace(/[<>]/g, "").replaceAll("`", "'"); -// One tool call: what ran stays visible; its result folds away. -function toolRow(item) { - const [label, detail] = step(item); - const state = item.status === "failed" +const toolState = (item) => + item.status === "failed" ? "失败" : item.status === "in_progress" ? grey("进行中") : item.status === "incomplete" ? grey("未完成") : typeof item.duration_ms === "number" - ? grey(`${(item.duration_ms / 1000).toFixed(1)}s`) - : ""; - const title = [grey(label), detail ? `\`${literal(clip(detail, 200))}\`` : "", state].filter(Boolean).join(" "); + ? grey(`${(item.duration_ms / 1000).toFixed(1)}s`) + : ""; + +// One tool call: the command is the title, its result folds below it. +function toolRow(item) { + const [label, detail] = step(item); + const title = [grey(label), detail ? `\`${literal(clip(detail, 200))}\`` : "", toolState(item)].filter(Boolean).join(" "); const fields = [ ["参数", item.command ? undefined : item.arguments ?? item.action], ["输出", item.output], ["错误", item.error], ["退出码", item.exit_code], @@ -300,23 +302,44 @@ function toolRow(item) { }); } -// Thinking folds behind its first sentence; commentary is short and shown as is. +// Consecutive tool calls fold into one row; each call folds again inside it. +function toolsRow(items) { + const failed = items.filter((item) => item.status === "failed").length; + const state = items.some((item) => item.status === "in_progress") + ? grey("进行中") + : failed + ? `失败 ${failed} 次` + : items.every((item) => typeof item.duration_ms === "number") + ? grey(`${(items.reduce((sum, item) => sum + item.duration_ms, 0) / 1000).toFixed(1)}s`) + : ""; + const [label, detail] = step(items[0]); + const title = items.length === 1 + ? [grey(label), detail ? `\`${literal(clip(detail, 40))}\`` : "", state] + : [grey(items.every((item) => step(item)[0] === "运行") ? `运行 ${items.length} 条命令` : `调用 ${items.length} 次工具`), state]; + return panel({ title: title.filter(Boolean).join(" "), elements: items.map(toolRow) }); +} + +// Only commentary stays open; thinking and tool calls fold behind one line. function timelineRow(entry) { if (entry.type === "say") return { tag: "markdown", content: excerpt(entry.text, 800) }; if (entry.type === "think") return panel({ title: `${grey("思考")} ${literal(clip(entry.text.split("\n").find((line) => line.trim()) || "", 40))}`, content: excerpt(entry.text, 1500) }); - return toolRow(entry.item); + return toolsRow(entry.items); } -// The oldest rows leave the card first when it would grow past Feishu's limit. -function fitRows(rows, budget) { +// The oldest rows leave the card first when it would grow past Feishu's +// limit; a single row that is still too large sheds its oldest calls. +function fitRows(rows, budget, unit = "步") { const kept = [...rows]; let dropped = 0; while (kept.length > 1 && bytes(kept) > budget) { kept.shift(); dropped++; } - return dropped ? [{ tag: "markdown", content: grey(`更早的 ${dropped} 步见网页对话`) }, ...kept] : kept; + const [only] = kept; + if (kept.length === 1 && bytes(kept) > budget && only.elements?.length > 1) + kept[0] = { ...only, elements: fitRows(only.elements, budget - (bytes(only) - bytes(only.elements)), "次调用") }; + return dropped ? [{ tag: "markdown", content: grey(`更早的 ${dropped} ${unit}见网页对话`) }, ...kept] : kept; } // A tool-result answer resumes the same Core Turn. Display only the exchange @@ -355,7 +378,13 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) const output = x.type === "function_call" ? outputs.get(x.call_id) : undefined; return [{ type: "tool", item: output ? { ...x, status: output.status, output: output.output, error: output.error } : x }]; }); - const rows = entries.map(timelineRow); + const grouped = entries.reduce((rows, entry) => { + const last = rows.at(-1); + if (entry.type === "tool" && last?.type === "tools") last.items.push(entry.item); + else rows.push(entry.type === "tool" ? { type: "tools", items: [entry.item] } : entry); + return rows; + }, []); + const rows = grouped.map(timelineRow); const tools = entries.filter((entry) => entry.type === "tool").map((entry) => entry.item); if (status === "in_progress") { const last = items.filter((x) => x.role !== "user" && x.type !== "function_call_output").at(-1); @@ -623,7 +652,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { if (store.get("feishu_inbound", id)) return; let thread = message.chatType === "p2p" && - !["/new", "/reset"].includes(message.content.trim()) + message.content.trim() !== "/clear" ? store .list("feishu_threads") .find( @@ -862,25 +891,11 @@ export function feishuWorker(store, core, product, makeChannel, configId) { const inbound = async (row) => { let thread = store.get("feishu_threads", row.thread_id); if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); - if (["/new", "/reset", "/help"].includes(row.text)) { - queueReply( - stableId(`${row.id}:command`), - thread, - row.text === "/help" - ? "直接发送消息即可对话。/new 开始新对话(群聊请另开话题并 @机器人);/cancel 停止执行;/answer <回答> 回复 Agent 的提问。" - : row.chat_type === "p2p" - ? "新对话已就绪,请直接发送消息。" - : "群聊请另开话题并 @机器人,开始新对话。当前话题保持不变。", - ); - store.put("feishu_inbound", { ...row, state: "delivered" }); - return; - } - const media = await mediaFor(row); - row = store.get("feishu_inbound", row.id); - const rotateP2PThread = () => { + // A new Session for this chat or topic; the next message starts it. + const rotateThread = () => { thread = { ...thread, - root_id: row.message_id, + root_id: row.chat_type === "p2p" ? row.message_id : thread.root_id, agent_id: store.get("feishu_bots", configId).agent_id, session_id: randomUUID(), session_ids: [ @@ -902,13 +917,40 @@ export function feishuWorker(store, core, product, makeChannel, configId) { }; store.put("feishu_threads", thread); }; + if (["/clear", "/help"].includes(row.text)) { + if (row.text === "/clear") { + // Work in the cleared conversation would continue unseen; stop it. + const previous = row.chat_type === "p2p" + ? store.list("feishu_threads").filter((t) => t.id !== thread.id && t.app_id === thread.app_id && t.chat_id === thread.chat_id && t.sender_id === thread.sender_id && t.chat_type === "p2p").at(-1) + : thread; + const run = previous && store.get("sessions", previous.session_id); + if (run?.core_session_id) + await core(`/v1/agents/sessions/${run.core_session_id}/events`, "POST", { events: [{ type: "agent.session.input.cancel" }] }, `${row.id}:clear`).catch(() => {}); + // A private chat already got a fresh thread on receipt; a topic keeps its root. + if (row.chat_type !== "p2p" && thread.first_message_id !== row.id) rotateThread(); + } + queueReply( + stableId(`${row.id}:command`), + thread, + row.text === "/help" + ? "直接发送消息即可对话。/clear 清空上下文,开始新对话;/stop 停止当前任务;/answer <回答> 回复 Agent 的提问。" + : row.chat_type === "p2p" + ? "已清空上下文,直接发送消息即可开始新对话。" + : "已清空这个话题的上下文,接着在话题里发消息即可开始新对话。", + ); + store.put("feishu_inbound", { ...row, state: "delivered" }); + return; + } + const media = await mediaFor(row); + row = store.get("feishu_inbound", row.id); + if ( thread.first_message_id !== row.id && row.chat_type === "p2p" && !row.text.startsWith("/") && store.get("sessions", thread.session_id)?.deleted_at ) - rotateP2PThread(); + rotateThread(); const progress = (text) => { const progressId = stableId(`${row.id}:accepted`); thread = { @@ -955,24 +997,24 @@ export function feishuWorker(store, core, product, makeChannel, configId) { return reject( row, row.chat_type === "p2p" - ? "此对话已不可用,请发送 /new 开始新对话。" + ? "此对话已不可用,请发送 /clear 开始新对话。" : "此对话已不可用,请另开话题并 @机器人。", ); if (!run.core_session_id) run = await sessions(run.id, {}); if ( row.text.startsWith("/") && - row.text !== "/cancel" && + row.text !== "/stop" && !row.text.startsWith("/answer ") ) return reject( row, - "支持的命令:/new、/cancel、/answer、/help。其他消息会交给 Agent。", + "支持的命令:/clear、/stop、/answer、/help。其他消息会交给 Agent。", ); try { if ( media.files.length && !row.files_uploaded && - row.text !== "/cancel" + row.text !== "/stop" ) { const session = await core( `/v1/agents/sessions/${run.core_session_id}`, @@ -992,7 +1034,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { store.put("feishu_inbound", row); } let answer; - if (!row.event && row.text !== "/cancel") { + if (!row.event && row.text !== "/stop") { const session = await core( `/v1/agents/sessions/${run.core_session_id}`, "GET", @@ -1021,7 +1063,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { const event = row.event || answer || - (row.text === "/cancel" + (row.text === "/stop" ? { type: "agent.session.input.cancel" } : { type: "agent.session.input.message", @@ -1063,11 +1105,11 @@ export function feishuWorker(store, core, product, makeChannel, configId) { } catch (error) { if ( row.chat_type === "p2p" && - row.text !== "/cancel" && + row.text !== "/stop" && (error?.code === "environment_unavailable" || /environment is no longer available/i.test(error?.message || "")) ) { - rotateP2PThread(); + rotateThread(); row = { ...row, state: "queued", @@ -1079,7 +1121,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { } throw error; } - if (row.text === "/cancel") + if (row.text === "/stop") queueReply( stableId(`${row.id}:cancel`), thread, @@ -1476,7 +1518,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { const cancellations = new Map(); for (const row of rows) if ( - row.text === "/cancel" && + row.text === "/stop" && ["queued", "submitting"].includes(row.state) ) cancellations.set(row.thread_id, row); @@ -1502,7 +1544,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { let row = store.get("feishu_inbound", original.id); if ( !allowed(row.app_id, row.chat_type) || - row.text === "/cancel" || + row.text === "/stop" || ["delivered", "failed", "rejected"].includes(row.state) || blocked.has(row.thread_id) ) diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index 1cc3200c1..453c74ba7 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -267,7 +267,7 @@ test("Feishu commands do not poison new topics and rejected controls do not bloc ...f.message, messageId: "om_cancel", rootId: "om_first", - content: "/cancel", + content: "/stop", }); await w.tick(); assert.ok( @@ -530,7 +530,7 @@ test("Feishu cancellation bypasses busy follow-ups and withdraws them permanentl ...f.message, messageId: "om_stop", rootId: "om_first", - content: "/cancel", + content: "/stop", }); await w.tick(); assert.deepEqual(events, [ @@ -554,7 +554,7 @@ test("Feishu can withdraw an unstarted conversation and recover a lost creation ...f.message, messageId: "om_stop", rootId: "om_first", - content: "/cancel", + content: "/stop", }); await w.tick(); assert.equal(f.receipts.size, 0); @@ -570,7 +570,7 @@ test("Feishu can withdraw an unstarted conversation and recover a lost creation ...f.message, messageId: "om_cancel_pending", rootId: "om_new", - content: "/cancel", + content: "/stop", }); await w.tick(); assert.equal(f.store.list("sessions").length, 1); @@ -924,25 +924,27 @@ test("Feishu context never reads earlier group messages", async () => { assert.equal(context.notice, ""); }); -test("Feishu group reset explains how to start a fresh conversation", async (t) => { +test("/clear in a topic stops its work and the next message there starts a new conversation", async (t) => { const f = await feishu(t), worker = f.create(); worker.receive("cli_0123456789abcdef", f.message); await worker.tick(); - const run = f.store.list("sessions")[0]; - worker.receive("cli_0123456789abcdef", { - ...f.message, - messageId: "om_reset", - rootId: "om_first", - content: "/new", - }); + const [run] = f.store.list("sessions"); + f.resources.get(run.core_session_id).status = "in_progress"; + worker.receive("cli_0123456789abcdef", { ...f.message, messageId: "om_clear", rootId: "om_first", content: "/clear" }); await worker.tick(); - assert.ok( - f.store - .list("feishu_outbound") - .some((row) => row.payload.includes("群聊请另开话题")), - ); - assert.equal(f.store.list("sessions")[0].id, run.id); + const stopped = f.calls.find((x) => x.body?.events?.[0].type === "agent.session.input.cancel"); + assert.ok(stopped, "running work in the cleared conversation is stopped"); + assert.ok(stopped.path.includes(run.core_session_id)); + assert.ok(f.store.list("feishu_outbound").some((row) => row.payload.includes("已清空这个话题的上下文"))); + worker.receive("cli_0123456789abcdef", { ...f.message, messageId: "om_after", rootId: "om_first", content: "Start over" }); + await worker.tick(); + const sessions = f.store.list("sessions"); + assert.equal(sessions.length, 2, "the topic now has a new conversation"); + const fresh = sessions.find((row) => row.id !== run.id); + assert.equal(fresh.name, "Start over"); + assert.equal(f.store.list("feishu_threads").length, 1, "the topic keeps one thread"); + assert.equal(f.store.list("feishu_threads")[0].session_id, fresh.id); }); test("Feishu terminal results survive histories beyond one hundred pages", async (t) => { @@ -1078,14 +1080,16 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(firstCard.schema, "2.0"); assert.equal(firstCard.config.summary.content, "处理中"); assert.deepEqual(firstCard.header, { title: { tag: "plain_text", content: "处理中" }, template: "blue" }); - // One chronological timeline: commentary as text, the command visible. + // One chronological timeline: only commentary stays open. const [current, said, ran] = firstCard.body.elements; assert.match(current.content, /正在整理结果/); assert.match(current.content, /2 步/); assert.equal(said.tag, "markdown"); assert.equal(said.content, "先读取飞书技能文档。"); - assert.equal(ran.tag, "markdown", "a command without output has nothing to fold"); - assert.match(ran.content, /运行.*`cat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md`.*0\.4s/); + assert.equal(ran.tag, "collapsible_panel"); + assert.equal(ran.expanded, false); + assert.match(ran.header.title.content, /运行.*`cat \/workspace\/\.agents\/skills\/lark-shar…`.*0\.4s/); + assert.match(ran.elements[0].content, /`cat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md`/); finishTurn = true; items.push({ @@ -1109,7 +1113,7 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(process.expanded, false); assert.match(process.header.title.content, /处理过程 · 2 步/); assert.equal(process.elements[0].content, "先读取飞书技能文档。"); - assert.match(process.elements[1].content, /`cat \/workspace/); + assert.match(process.elements[1].elements[0].content, /`cat \/workspace/); assert.doesNotMatch( JSON.stringify(patchedCard), /任务编号|在 Web 会话下载|Parsar/, @@ -1556,7 +1560,7 @@ test("Feishu SDK uploads multipart bytes and preserves definite rejection and re await assert.rejects(disconnected.upload(Buffer.from("file"), "file.txt"), (error) => error.status === 503 && !error.message.includes("private-network-data")); }); -test("the timeline joins tool results, keeps commands visible and folds their diagnostics", async () => { +test("consecutive tool calls fold into one row, each joined with its result and folding its diagnostics", async () => { const { formatTurnCard } = await import("../server/feishu.mjs"); const card = JSON.parse(formatTurnCard("in_progress", [ { type: "reasoning", summary: [{ text: "先检查接口,再处理结果。" }] }, @@ -1565,10 +1569,13 @@ test("the timeline joins tool results, keeps commands visible and folds their di { type: "command_execution", command: "pwd", cwd: "/workspace", output: "/workspace", exit_code: 0, duration_ms: 420, status: "completed" }, { type: "mcp_call", name: "read", server_label: "docs", arguments: { id: 3 }, status: "incomplete" }, ])); - const [current, thought, lookup, pwd, read] = card.body.elements; - assert.match(current.content, /4 步/); + const [current, thought, calls] = card.body.elements; + assert.match(current.content, /2 步/); assert.match(thought.header.title.content, /思考.*先检查接口/); assert.equal(thought.elements[0].content, "先检查接口,再处理结果。"); + assert.equal(calls.tag, "collapsible_panel"); + assert.match(calls.header.title.content, /调用 3 次工具.*失败 1 次<\/font>/); + const [lookup, pwd, read] = calls.elements; assert.match(lookup.header.title.content, /调用工具.*`lookup`.*失败<\/font>/); const literal = lookup.elements[0].text; assert.equal(literal.tag, "plain_text"); @@ -1689,7 +1696,7 @@ test("large multilingual diagnostics stay below the Feishu card payload limit", for (const status of ["in_progress", "completed", "requires_action"]) { const payload = formatTurnCard(status, items, undefined, undefined, [{ arguments: { question: large, options: [large] } }]); assert.ok(Buffer.byteLength(payload) < 30000, `${status}: ${Buffer.byteLength(payload)}`); - assert.match(payload, /更早的 \d+ 步见网页对话/, "the oldest rows leave the card first"); + assert.match(payload, /更早的 \d+ (步|次调用)见网页对话/, "the oldest rows leave the card first"); assert.match(payload, /内容较长,已截取/); } }); @@ -1986,14 +1993,17 @@ test("completed-turn cursors release answered questions before the next turn", a test("a running card shows the latest four steps and folds the earlier ones", async () => { const { formatTurnCard } = await import("../server/feishu.mjs"); - const items = Array.from({ length: 7 }, (_, i) => ({ type: "command_execution", command: `step ${i + 1}`, duration_ms: 1000, status: "completed" })); - items.push({ type: "command_execution", command: "step 8", status: "in_progress" }); + const items = [1, 2, 3, 4].flatMap((i) => [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: `说明 ${i}` }] }, + { type: "command_execution", command: `step ${i}`, duration_ms: 1000, status: i === 4 ? "in_progress" : "completed" }, + ]); const [current, earlier, ...recent] = JSON.parse(formatTurnCard("in_progress", items)).body.elements; + const label = (row) => row.tag === "markdown" ? row.content : row.header.title.content.match(/`(.*)`/)[1]; assert.match(current.content, /正在运行.*8 步/); assert.equal(earlier.tag, "collapsible_panel"); assert.equal(earlier.expanded, false); assert.match(earlier.header.title.content, /更早的 4 步/); - assert.deepEqual(earlier.elements.map((row) => row.content.match(/`(.*)`/)[1]), ["step 1", "step 2", "step 3", "step 4"]); - assert.deepEqual(recent.map((row) => row.content.match(/`(.*)`/)[1]), ["step 5", "step 6", "step 7", "step 8"]); - assert.match(recent.at(-1).content, /进行中/); + assert.deepEqual(earlier.elements.map(label), ["说明 1", "step 1", "说明 2", "step 2"]); + assert.deepEqual(recent.map(label), ["说明 3", "step 3", "说明 4", "step 4"]); + assert.match(recent.at(-1).header.title.content, /进行中/); }); From 08ba416420aeced647b6064bafaa78f92a774794 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 25 Oct 2026 22:24:27 +0800 Subject: [PATCH 03/19] Move the Feishu running status into the header and tint folded history The running card's status line took the first body row. The header now carries what is happening and how many steps have run, so the timeline starts the body. Earlier steps and a finished card's process sit in a tinted, rounded block, and a divider separates earlier steps from the latest ones. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 2 +- example/parsar/README.zh.md | 4 +-- example/parsar/server/feishu.mjs | 39 ++++++++++++++++++---------- example/parsar/tests/feishu.test.mjs | 25 +++++++++++------- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/example/parsar/README.md b/example/parsar/README.md index ee75259c1..4ce16f1bb 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -123,7 +123,7 @@ Optional business questions use the declared `ask_user` function. They appear in 4. Text, rich posts, images, files, audio and video, quoted messages and merged forwards are normalized before dispatch. Attachment limits are eight resources and 5 MiB total. Follow-up files wait until the conversation is idle. Quoted messages are read up to four levels; earlier group messages are not read. 5. Commands: `/clear` clears the context: it stops any running work and the next message starts a new conversation, in a private chat or in the same group topic. `/stop` stops the current task; it takes priority over queued follow-ups and recovers an uncertain creation before stopping it. `/answer ` answers a pending business question, and a plain reply answers when only one is pending. `/help` lists the commands. -Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. The card has a colored header for its status. Its process is one timeline in the order things happened. Only commentary stays open as plain text; thinking folds behind its first sentence, and consecutive tool calls fold into one row (such as `运行 3 条命令` with their total time, failures or 进行中) in which each call folds again behind its command, showing parameters, output, error and exit code. A running card shows the latest four steps and folds the earlier ones; a finished card puts the answer first and folds the whole process into **处理过程**; a failed card shows the failed step right under its cause. Long content is cut to marked, byte-bounded excerpts, and the oldest steps leave the card first to stay within Feishu's card size limit. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. +Accepted messages are committed to SQLite before the WebSocket ACK; request IDs and frozen payloads survive retries and restarts. The worker edits one compact progress card in place, includes any pending business question in that card, and finishes it with the result. Each reply shows only the current exchange, including answers that resume the same Core Turn. The answer stays visible; while awaiting a business decision, the question takes priority and its supporting context is collapsed, except QR authorization instructions. The card has a colored header for its status. Its process is one timeline in the order things happened. Only commentary stays open as plain text; thinking folds behind its first sentence, and consecutive tool calls fold into one row (such as `运行 3 条命令` with their total time, failures or 进行中) in which each call folds again behind its command, showing parameters, output, error and exit code. While running, the header also says what is happening now and how many steps have run. A running card shows the latest four steps and folds the earlier ones into a tinted block above a divider; a finished card puts the answer first and folds the whole process into a tinted **处理过程** block; a failed card shows the failed step right under its cause. Long content is cut to marked, byte-bounded excerpts, and the oldest steps leave the card first to stay within Feishu's card size limit. Progress uses five-second polling. Complete history and Artifacts remain in the Web conversation. Settled threads stop polling after five minutes and wake on new messages. **消息记录** lists the latest 100 deliveries plus unresolved ones; **重试** keeps the original identity. A failed inbound message blocks later messages in that thread. Uncertain outbound sends are not replayed beyond 55 minutes. Disabling a bot pauses delivery without cancelling Core work. ### Bot identity diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index dce57e365..df9936711 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: e82aa7249febf32add4459dabd95f6814b2cade69d0bd5ab962cad2f12d10acb +source_hash: 91f8e0d2abc4d4bb989358f846e6b212fbb38e764a953a1d175c17e6b3c640ea --- # Parsar 工作空间 @@ -128,7 +128,7 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 5 MiB。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 5. 命令:`/clear` 清空上下文:停止正在执行的任务,下一条消息开始新对话,私聊和群聊话题都适用(群聊仍在同一个话题里)。`/stop` 停止当前任务,优先于排队中的追问,并会先确认不确定的创建再停止。`/answer <回答>` 回答待处理的业务问题;只有一个待回答的问题时,直接回复即可。`/help` 列出这些命令。 -收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。卡片顶部是带颜色的状态标题栏。处理过程按发生顺序排成一条时间线。只有说明直接显示文字;思考折叠在第一句后面;连续的工具调用折叠成一行(例如“运行 3 条命令”,附总用时、失败次数或“进行中”),展开后每次调用再折叠在它的命令后面,可查看参数、输出、错误和退出码。执行中的卡片显示最近四步,更早的步骤折叠;完成后回复放在最前面,整个过程折叠成 **处理过程**;失败时出错的那一步紧跟在失败原因下面。长内容按字节限制截取并明确标注;为了不超过飞书卡片的大小限制,最早的步骤会先移出卡片。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 +收到的消息在 WebSocket ACK 之前写入 SQLite;请求 ID 和冻结的负载在重试与重启后保持不变。工作器原地更新一张简洁进度卡片,将待回答的问题合并到卡片中,结束后改成结果。每次回复只展示当前交流的内容,包括回答提问后继续同一 Core Turn 的情况。正文突出当前回复;等待业务选择时优先展示问题,将补充说明折叠,二维码授权说明保持可见。卡片顶部是带颜色的状态标题栏。处理过程按发生顺序排成一条时间线。只有说明直接显示文字;思考折叠在第一句后面;连续的工具调用折叠成一行(例如“运行 3 条命令”,附总用时、失败次数或“进行中”),展开后每次调用再折叠在它的命令后面,可查看参数、输出、错误和退出码。执行中时,标题栏还会显示当前在做什么和已执行的步数;卡片显示最近四步,更早的步骤折叠进分隔线上方的浅色区块;完成后回复放在最前面,整个过程折叠进浅色的 **处理过程** 区块;失败时出错的那一步紧跟在失败原因下面。长内容按字节限制截取并明确标注;为了不超过飞书卡片的大小限制,最早的步骤会先移出卡片。进度每五秒轮询一次。完整历史和 Artifacts 保留在网页对话中。已结束的话题在五分钟后停止轮询,收到新消息时恢复。**消息记录** 列出最近 100 条投递以及未解决的记录;**重试** 保留原有标识。入站消息失败会阻塞该话题后续消息。不确定的出站消息超过 55 分钟后不再重放。停用机器人会暂停投递,但不会取消 Core 中的执行。 ### 机器人身份 {#bot-identity} diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index e5fbf6395..9b0e74382 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -192,16 +192,18 @@ function excerpt(value, limit) { return prefix.join("") + "\n…(内容较长,已截取)"; } -function panel({ title, content, elements }) { +// A `block` panel is a tinted, rounded area that holds folded history. +function panel({ title, content, elements, block = false }) { return { tag: "collapsible_panel", expanded: false, vertical_spacing: "8px", - padding: "0px 0px 8px 0px", + padding: block ? "0px 12px 8px 12px" : "0px 0px 8px 0px", + ...(block && { background_color: "grey-50", border: { color: "grey-50", corner_radius: "8px" } }), header: { title: { tag: "markdown", content: title }, width: "fill", - padding: "6px 0px 6px 0px", + padding: block ? "8px 0px 8px 0px" : "6px 0px 6px 0px", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px" }, icon_position: "right", icon_expanded_angle: -180, @@ -214,17 +216,24 @@ function panel({ title, content, elements }) { const cardBudget = 28000; const bytes = (value) => Buffer.byteLength(JSON.stringify(value)); -function statusCard(status, body, elements = []) { +// The colored header carries the status and, while running, what is +// happening now, so the body starts with the content. +function statusCard(status, body, elements = [], subtitle = "") { const [title, template] = cardTones[status] || cardTones.failed; + // A card needs a body; with nothing else to show, what is happening is the body. + if (!body && !elements.length && subtitle) [body, subtitle] = [`**${subtitle}**`, ""]; return JSON.stringify({ schema: "2.0", - config: { update_multi: true, summary: { content: title } }, - // The colored header carries the status, so the body starts with the content. - header: { title: { tag: "plain_text", content: title }, template }, + config: { update_multi: true, summary: { content: subtitle ? `${title} · ${subtitle}` : title } }, + header: { + title: { tag: "plain_text", content: title }, + ...(subtitle && { subtitle: { tag: "plain_text", content: subtitle } }), + template, + }, body: { padding: "12px 16px 12px 16px", vertical_spacing: "8px", - elements: [{ tag: "markdown", content: body }, ...elements], + elements: [...(body ? [{ tag: "markdown", content: body }] : []), ...elements], }, }); } @@ -243,7 +252,7 @@ export function feishuCard(payload) { } export function progressCard(text, notice) { - return statusCard("in_progress", `**${text}**${notice ? `\n${grey(notice)}` : ""}`); + return statusCard("in_progress", notice ? grey(notice) : "", [], text); } function questionText(action, includeOptions = true) { @@ -391,12 +400,16 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) const current = !last ? waitingHeadline(turn) : last.type === "message" ? "正在整理回复" : last.type === "reasoning" ? "正在思考" : last.status === "in_progress" ? `正在${step(last)[0]}` : "正在整理结果"; const typical = !last && turn?.expected_s && turn.stage !== "retrying" ? typicalWait.replace("{{value0}}", turn.expected_s) : ""; const progress = rows.length ? `${rows.length} 步` : typical; - const body = excerpt([`**${current}**${progress ? ` ${grey(`· ${progress}`)}` : ""}`, authorizationText].filter(Boolean).join("\n"), 8000); + const subtitle = [current, progress].filter(Boolean).join(" · "); + const body = authorizationText ? excerpt(authorizationText, 8000) : ""; const earlier = rows.slice(0, -recentRows), recent = rows.slice(-recentRows); const budget = cardBudget - bytes(body); const shown = fitRows(recent, budget); - const fold = earlier.length ? [panel({ title: grey(`更早的 ${earlier.length} 步`), elements: fitRows(earlier, budget - bytes(shown)) })] : []; - return statusCard(status, body, [...fold, ...shown]); + // Earlier steps sit in a tinted block above a rule that separates the latest ones. + const fold = earlier.length + ? [panel({ title: grey(`更早的 ${earlier.length} 步`), elements: fitRows(earlier, budget - bytes(shown)), block: true }), { tag: "hr" }] + : []; + return statusCard(status, body, [...fold, ...shown], subtitle); } const context = questions.some((action) => questionText(action, false).trim() === answer.trim()) ? "" : answer; // Put the decision first; keep its explanation available without filling the chat. @@ -414,7 +427,7 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) if (failedTool) elements.push(toolRow(failedTool)); if (rows.length) { const budget = cardBudget - bytes(body) - bytes(elements); - elements.push(panel({ title: grey(`处理过程 · ${rows.length} 步`), elements: fitRows(rows, budget) })); + elements.push(panel({ title: grey(`处理过程 · ${rows.length} 步`), elements: fitRows(rows, budget), block: true })); } return statusCard(status, body, elements.length ? [{ tag: "hr" }, ...elements] : []); } diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index 453c74ba7..467368b00 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -1078,12 +1078,14 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(f.sent.length, 1); const firstCard = JSON.parse(f.sent[0].data.content); assert.equal(firstCard.schema, "2.0"); - assert.equal(firstCard.config.summary.content, "处理中"); - assert.deepEqual(firstCard.header, { title: { tag: "plain_text", content: "处理中" }, template: "blue" }); - // One chronological timeline: only commentary stays open. - const [current, said, ran] = firstCard.body.elements; - assert.match(current.content, /正在整理结果/); - assert.match(current.content, /2 步/); + assert.equal(firstCard.config.summary.content, "处理中 · 正在整理结果 · 2 步"); + // The header says what is happening now; the body starts with the timeline. + assert.deepEqual(firstCard.header, { + title: { tag: "plain_text", content: "处理中" }, + subtitle: { tag: "plain_text", content: "正在整理结果 · 2 步" }, + template: "blue", + }); + const [said, ran] = firstCard.body.elements; assert.equal(said.tag, "markdown"); assert.equal(said.content, "先读取飞书技能文档。"); assert.equal(ran.tag, "collapsible_panel"); @@ -1569,8 +1571,8 @@ test("consecutive tool calls fold into one row, each joined with its result and { type: "command_execution", command: "pwd", cwd: "/workspace", output: "/workspace", exit_code: 0, duration_ms: 420, status: "completed" }, { type: "mcp_call", name: "read", server_label: "docs", arguments: { id: 3 }, status: "incomplete" }, ])); - const [current, thought, calls] = card.body.elements; - assert.match(current.content, /2 步/); + assert.match(card.header.subtitle.content, /2 步/); + const [thought, calls] = card.body.elements; assert.match(thought.header.title.content, /思考.*先检查接口/); assert.equal(thought.elements[0].content, "先检查接口,再处理结果。"); assert.equal(calls.tag, "collapsible_panel"); @@ -1997,12 +1999,15 @@ test("a running card shows the latest four steps and folds the earlier ones", as { type: "message", role: "assistant", content: [{ type: "output_text", text: `说明 ${i}` }] }, { type: "command_execution", command: `step ${i}`, duration_ms: 1000, status: i === 4 ? "in_progress" : "completed" }, ]); - const [current, earlier, ...recent] = JSON.parse(formatTurnCard("in_progress", items)).body.elements; + const card = JSON.parse(formatTurnCard("in_progress", items)); + const [earlier, rule, ...recent] = card.body.elements; const label = (row) => row.tag === "markdown" ? row.content : row.header.title.content.match(/`(.*)`/)[1]; - assert.match(current.content, /正在运行.*8 步/); + assert.equal(card.header.subtitle.content, "正在运行 · 8 步"); assert.equal(earlier.tag, "collapsible_panel"); assert.equal(earlier.expanded, false); assert.match(earlier.header.title.content, /更早的 4 步/); + assert.equal(earlier.background_color, "grey-50", "earlier steps sit in a tinted block"); + assert.equal(rule.tag, "hr", "a rule separates them from the latest steps"); assert.deepEqual(earlier.elements.map(label), ["说明 1", "step 1", "说明 2", "step 2"]); assert.deepEqual(recent.map(label), ["说明 3", "step 3", "说明 4", "step 4"]); assert.match(recent.at(-1).header.title.content, /进行中/); From 5020762a33ff75007b91737af44267c4c7d9e576 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 25 Oct 2026 22:57:37 +0800 Subject: [PATCH 04/19] Keep Feishu results in their own card and Markdown images out of cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu treats any Markdown image as an image key and rejects the whole card. Thinking that quoted the authorization QR image made every edit of that Turn's card fail, silently, so the card stayed 处理中. When the next message arrived, the older Turn's result took the new message's progress card (with its content cut by the new display boundary), and the new Turn's answer edited the topic's first card instead. Markdown images now become text in every card row. Each Turn's result goes to the progress card of the input it answers, found through the Turn ledger; an older Turn never takes a newer message's card, and there is no fallback to the conversation's first card. An older Turn shows all of its items. Observation failures are logged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/server/feishu.mjs | 156 +++++++++++++++------------ example/parsar/server/turns.mjs | 5 +- example/parsar/tests/feishu.test.mjs | 68 +++++++++--- 3 files changed, 141 insertions(+), 88 deletions(-) diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index 9b0e74382..d05185268 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -197,13 +197,14 @@ function panel({ title, content, elements, block = false }) { return { tag: "collapsible_panel", expanded: false, - vertical_spacing: "8px", - padding: block ? "0px 12px 8px 12px" : "0px 0px 8px 0px", + vertical_spacing: "4px", + padding: block ? "0px 12px 8px 12px" : "0px 0px 4px 0px", ...(block && { background_color: "grey-50", border: { color: "grey-50", corner_radius: "8px" } }), header: { title: { tag: "markdown", content: title }, width: "fill", - padding: block ? "8px 0px 8px 0px" : "6px 0px 6px 0px", + // The header ignores the panel padding, so a block indents its own title. + padding: block ? "8px 12px 8px 12px" : "2px 0px 2px 0px", icon: { tag: "standard_icon", token: "down-small-ccm_outlined", size: "16px 16px" }, icon_position: "right", icon_expanded_angle: -180, @@ -217,22 +218,19 @@ const cardBudget = 28000; const bytes = (value) => Buffer.byteLength(JSON.stringify(value)); // The colored header carries the status and, while running, what is -// happening now, so the body starts with the content. +// happening now on the same line, so the body starts with the content. function statusCard(status, body, elements = [], subtitle = "") { const [title, template] = cardTones[status] || cardTones.failed; // A card needs a body; with nothing else to show, what is happening is the body. if (!body && !elements.length && subtitle) [body, subtitle] = [`**${subtitle}**`, ""]; + const heading = subtitle ? `${title} · ${subtitle}` : title; return JSON.stringify({ schema: "2.0", - config: { update_multi: true, summary: { content: subtitle ? `${title} · ${subtitle}` : title } }, - header: { - title: { tag: "plain_text", content: title }, - ...(subtitle && { subtitle: { tag: "plain_text", content: subtitle } }), - template, - }, + config: { update_multi: true, summary: { content: heading } }, + header: { title: { tag: "plain_text", content: heading }, template }, body: { padding: "12px 16px 12px 16px", - vertical_spacing: "8px", + vertical_spacing: "6px", elements: [...(body ? [{ tag: "markdown", content: body }] : []), ...elements], }, }); @@ -277,12 +275,32 @@ function step(item) { return ["执行步骤", item.name || item.type || ""]; } +// Wide (CJK and full-width) characters take two columns in a card; general +// punctuation such as the ellipsis is narrow. +const wide = (char) => { + const code = char.codePointAt(0); + return code >= 0x1100 && !(code >= 0x2000 && code <= 0x206f); +}; +const columns = (text) => [...text].reduce((sum, char) => sum + (wide(char) ? 2 : 1), 0); const clip = (value, limit) => { const text = String(value ?? "").replace(/\s+/g, " ").trim(); - return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; + if (columns(text) <= limit) return text; + let kept = ""; + for (const char of text) { + if (columns(kept + char) > limit - 1) break; + kept += char; + } + return `${kept}…`; }; // Markdown and Feishu tags stay literal inside a one-line title. const literal = (value) => value.replace(/[<>]/g, "").replaceAll("`", "'"); +// Thinking and tool rows stay on one line: the detail gets what the label +// and state leave of the row. +const rowColumns = 52; +const oneLine = (label, detail, state = "") => { + const room = rowColumns - columns(label) - columns(state.replace(/<[^>]*>/g, "")) - 2; + return [grey(label), detail ? literal(clip(detail, Math.max(8, room))) : "", state].filter(Boolean).join(" "); +}; const toolState = (item) => item.status === "failed" @@ -298,8 +316,10 @@ const toolState = (item) => // One tool call: the command is the title, its result folds below it. function toolRow(item) { const [label, detail] = step(item); - const title = [grey(label), detail ? `\`${literal(clip(detail, 200))}\`` : "", toolState(item)].filter(Boolean).join(" "); + const title = oneLine(label, detail, toolState(item)); const fields = [ + // A shortened title keeps the whole command one click away. + ["命令", item.command && title.includes("…") ? detail : undefined], ["参数", item.command ? undefined : item.arguments ?? item.action], ["输出", item.output], ["错误", item.error], ["退出码", item.exit_code], ].filter(([, value]) => value !== undefined && value !== null && value !== ""); @@ -323,16 +343,16 @@ function toolsRow(items) { : ""; const [label, detail] = step(items[0]); const title = items.length === 1 - ? [grey(label), detail ? `\`${literal(clip(detail, 40))}\`` : "", state] - : [grey(items.every((item) => step(item)[0] === "运行") ? `运行 ${items.length} 条命令` : `调用 ${items.length} 次工具`), state]; - return panel({ title: title.filter(Boolean).join(" "), elements: items.map(toolRow) }); + ? oneLine(label, detail, state) + : [grey(items.every((item) => step(item)[0] === "运行") ? `运行 ${items.length} 条命令` : `调用 ${items.length} 次工具`), state].filter(Boolean).join(" "); + return panel({ title, elements: items.map(toolRow) }); } // Only commentary stays open; thinking and tool calls fold behind one line. function timelineRow(entry) { if (entry.type === "say") return { tag: "markdown", content: excerpt(entry.text, 800) }; if (entry.type === "think") - return panel({ title: `${grey("思考")} ${literal(clip(entry.text.split("\n").find((line) => line.trim()) || "", 40))}`, content: excerpt(entry.text, 1500) }); + return panel({ title: oneLine("思考", entry.text.split("\n").find((line) => line.trim()) || ""), content: excerpt(entry.text, 1500) }); return toolsRow(entry.items); } @@ -366,9 +386,12 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) const text = (item) => (item.content || []).filter((p) => p.type === "output_text").map((p) => (p.text || "").trim()).filter(Boolean).join("\n"); const messages = items.filter((x) => x.type === "message" && x.role === "assistant"); const latest = messages.at(-1); + // Feishu reads any Markdown image as an image key and rejects the whole + // card, so images become text; the files themselves are sent as messages. const readable = (value) => value .replace(/!\[[^\]\n]*\]\((\/app\/feishu\/provision\/[a-f0-9-]{36}\/qrcode)\)/g, "(授权二维码见图片消息)") - .replace(/!?\[([^\]]*)\]\((?:\/workspace\/|\.\/)?outputs\/[^)]*\)/g, "$1(见附件)"); + .replace(/!?\[([^\]]*)\]\((?:\/workspace\/|\.\/)?outputs\/[^)]*\)/g, "$1(见附件)") + .replace(/!\[([^\]\n]*)\]\([^)\n]*\)/g, (_, alt) => alt ? `(图片:${alt})` : "(图片)"); const authorization = messages.filter((item) => qrImages(text(item)).length); const authorizationText = authorization.map((item) => readable(text(item))).join("\n\n"); const answer = latest?.phase === "commentary" || authorization.includes(latest) ? "" : latest ? readable(text(latest)) : ""; @@ -376,7 +399,7 @@ export function formatTurnCard(status, items = [], error, turn, questions = []) // Thinking, commentary and tool calls in the order they happened. const entries = items.flatMap((x) => { if (x.type === "reasoning") { - const thought = (x.summary || x.content || []).map((p) => typeof p === "string" ? p : p?.text || "").filter(Boolean).join("\n\n").trim(); + const thought = readable((x.summary || x.content || []).map((p) => typeof p === "string" ? p : p?.text || "").filter(Boolean).join("\n\n").trim()); return thought ? [{ type: "think", text: thought }] : []; } if (x.type === "message") { @@ -1231,10 +1254,14 @@ export function feishuWorker(store, core, product, makeChannel, configId) { thread, `执行失败\n\n${ledger.summary(run.id)?.failure || "执行环境准备失败,请重新发送消息重试。"}`, ); - const progressId = - thread.active_outbound_id || - stableId(`${thread.first_message_id}:accepted`); - let progress = store.get("feishu_outbound", progressId); + let progress = thread.active_outbound_id && store.get("feishu_outbound", thread.active_outbound_id); + // The progress card of the input a Turn answers; an older Turn never takes + // the card of a newer message. + const cardFor = (turn) => { + const entry = ledger.entryFor(run.id, turn.id); + const own = entry && store.get("feishu_outbound", stableId(`${entry.kind === "first" ? thread.first_message_id : entry.id}:accepted`)); + return own || (turn.id === turns.at(-1)?.id ? progress : undefined); + }; const progressReplyTo = progress?.reply_to || thread.root_id; const questionBase = progress?.sent_message_id ? progress.id : thread.id; if ((progress || session.status === "requires_action") && (["in_progress", "requires_action"].includes(session.status) || recovering) && !unseen.length) { @@ -1304,67 +1331,53 @@ export function feishuWorker(store, core, product, makeChannel, configId) { turnItems.some((item) => item.type === "function_call" && item.call_id === callId) && !turnItems.some((item) => item.type === "function_call_output" && item.call_id === callId))) return; queueMedia(thread, turnItems, artifacts.filter((file) => file.turn_id === turn.id)); + const latest = turn.id === turns.at(-1)?.id; + const card = cardFor(turn); + // The display boundary belongs to the newest input; an older Turn shows all of itself. const text = formatTurnCard( turn.status, - currentItems.filter((item) => item.turn_id === turn.id), + latest ? currentItems.filter((item) => item.turn_id === turn.id) : turnItems, turn.status === "failed" ? ledger.summary(run.id)?.failure || explainFailure(turn.error) : turn.error?.message, ); const receiptId = stableId(`${thread.id}:${turn.id}`); - if (progress?.sent_message_id) { + const receipt = (sentMessageId) => store.put("feishu_outbound", { + id: receiptId, + app_id: thread.app_id, + chat_id: thread.chat_id, + chat_type: thread.chat_type, + thread_id: thread.id, + reply_to: card.reply_to || thread.root_id, + payload: text, + notify: false, + state: "delivered", + sent_message_id: sentMessageId, + attempts: 1, + created_at: stamp(), + next_at: 0, + }); + // The newest message's card is free again once its Turn has its result. + const release = () => { + if (card.id !== progress?.id) return; + thread = { ...thread, progress_text: text, active_outbound_id: undefined }; + progress = undefined; + }; + if (card?.sent_message_id) { try { - await channel.edit(progress.sent_message_id, text, thread); - store.put("feishu_outbound", { ...progress, payload: text, state: "delivered", error: undefined }); - store.put("feishu_outbound", { - id: receiptId, - app_id: thread.app_id, - chat_id: thread.chat_id, - chat_type: thread.chat_type, - thread_id: thread.id, - reply_to: progress.reply_to || thread.root_id, - payload: text, - notify: false, - state: "delivered", - sent_message_id: progress.sent_message_id, - attempts: 1, - created_at: stamp(), - next_at: 0, - }); - thread = { - ...thread, - progress_text: text, - active_outbound_id: undefined, - }; - progress = undefined; + await channel.edit(card.sent_message_id, text, thread); + store.put("feishu_outbound", { ...card, payload: text, state: "delivered", error: undefined }); + receipt(card.sent_message_id); + release(); continue; } catch (error) { if (error.code !== "feishu_card_unavailable") throw error; // A confirmed uneditable card falls back to sending a result card. } - } else if (progress && progress.state === "queued") { - store.put("feishu_outbound", { ...progress, payload: text }); - store.put("feishu_outbound", { - id: receiptId, - app_id: thread.app_id, - chat_id: thread.chat_id, - chat_type: thread.chat_type, - thread_id: thread.id, - reply_to: progress.reply_to || thread.root_id, - payload: text, - notify: false, - state: "delivered", - sent_message_id: progress.id, - attempts: 1, - created_at: stamp(), - next_at: 0, - }); - thread = { - ...thread, - progress_text: text, - active_outbound_id: undefined, - }; - progress = undefined; + } else if (card?.state === "queued") { + store.put("feishu_outbound", { ...card, payload: text }); + receipt(card.id); + release(); continue; } queueReply(receiptId, thread, text); @@ -1598,6 +1611,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { try { await observe(thread); } catch (error) { + console.error(`Feishu thread ${thread.id}: ${error.message}`); health("core_unavailable"); if ( !(error instanceof AppError) || diff --git a/example/parsar/server/turns.mjs b/example/parsar/server/turns.mjs index 80fff3ea8..5d53fd8f5 100644 --- a/example/parsar/server/turns.mjs +++ b/example/parsar/server/turns.mjs @@ -356,6 +356,9 @@ export function turnsAPI(store, core) { const superseded = (sessionId, coreTurnId) => (store.get("sessions", sessionId)?.superseded_turns || []).includes(coreTurnId); const recovering = (sessionId) => store.list("turns").some((t) => t.session_id === sessionId && t.outcome === "running"); + // The recorded input that a Core Turn answers. + const entryFor = (sessionId, coreTurnId) => + store.list("turns").find((t) => t.session_id === sessionId && t.core_turn_id === coreTurnId); - return { start, sendPrepared, deliver, send, monitor, summary, overview, health, retry, superseded, recovering }; + return { start, sendPrepared, deliver, send, monitor, summary, overview, health, retry, superseded, recovering, entryFor }; } diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index 467368b00..447c14bc4 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -1080,18 +1080,16 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(firstCard.schema, "2.0"); assert.equal(firstCard.config.summary.content, "处理中 · 正在整理结果 · 2 步"); // The header says what is happening now; the body starts with the timeline. - assert.deepEqual(firstCard.header, { - title: { tag: "plain_text", content: "处理中" }, - subtitle: { tag: "plain_text", content: "正在整理结果 · 2 步" }, - template: "blue", - }); + assert.deepEqual(firstCard.header, { title: { tag: "plain_text", content: "处理中 · 正在整理结果 · 2 步" }, template: "blue" }); const [said, ran] = firstCard.body.elements; assert.equal(said.tag, "markdown"); assert.equal(said.content, "先读取飞书技能文档。"); assert.equal(ran.tag, "collapsible_panel"); assert.equal(ran.expanded, false); - assert.match(ran.header.title.content, /运行.*`cat \/workspace\/\.agents\/skills\/lark-shar…`.*0\.4s/); - assert.match(ran.elements[0].content, /`cat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md`/); + // One line per row: a long command is shortened, and its full text is one click away. + assert.match(ran.header.title.content, /运行.* cat \/workspace\/\.agents\/skills\/.*….*0\.4s/); + assert.match(ran.elements[0].header.title.content, /运行.* cat \/workspace.*…/); + assert.match(ran.elements[0].elements[0].text.content, /命令\ncat \/workspace\/\.agents\/skills\/lark-shared\/SKILL\.md/); finishTurn = true; items.push({ @@ -1115,7 +1113,7 @@ test("Feishu updates a single card in place with thinking and steps, omits Web t assert.equal(process.expanded, false); assert.match(process.header.title.content, /处理过程 · 2 步/); assert.equal(process.elements[0].content, "先读取飞书技能文档。"); - assert.match(process.elements[1].elements[0].content, /`cat \/workspace/); + assert.match(process.elements[1].elements[0].header.title.content, /cat \/workspace/); assert.doesNotMatch( JSON.stringify(patchedCard), /任务编号|在 Web 会话下载|Parsar/, @@ -1404,7 +1402,7 @@ test("a failure shows its cause with the failed step right below, and a plain an assert.equal(card.config.summary.content, "执行失败"); const [cause, , failed, process] = card.body.elements; assert.match(cause.content, /permission denied<\/font>/); - assert.match(failed.content, /运行.*`lark-cli docs \+fetch --doc abc`.*失败<\/font>/); + assert.match(failed.content, /运行.* lark-cli docs \+fetch --doc abc .*失败<\/font>/); assert.match(process.header.title.content, /处理过程 · 1 步/); const quiet = JSON.parse( formatTurnCard("completed", [ @@ -1435,7 +1433,7 @@ test("web searches appear as readable steps in the Feishu record", async () => { ]), ); const search = card.body.elements[2].elements[0]; - assert.match(search.header.title.content, /搜索网页.*`OpenAgentCore release`/); + assert.match(search.header.title.content, /搜索网页.* OpenAgentCore release/); }); test("Feishu delivers QR during authorization and artifact media once, retaining uploaded keys after a lost send", async (t) => { @@ -1571,22 +1569,22 @@ test("consecutive tool calls fold into one row, each joined with its result and { type: "command_execution", command: "pwd", cwd: "/workspace", output: "/workspace", exit_code: 0, duration_ms: 420, status: "completed" }, { type: "mcp_call", name: "read", server_label: "docs", arguments: { id: 3 }, status: "incomplete" }, ])); - assert.match(card.header.subtitle.content, /2 步/); + assert.match(card.header.title.content, /2 步/); const [thought, calls] = card.body.elements; assert.match(thought.header.title.content, /思考.*先检查接口/); assert.equal(thought.elements[0].content, "先检查接口,再处理结果。"); assert.equal(calls.tag, "collapsible_panel"); assert.match(calls.header.title.content, /调用 3 次工具.*失败 1 次<\/font>/); const [lookup, pwd, read] = calls.elements; - assert.match(lookup.header.title.content, /调用工具.*`lookup`.*失败<\/font>/); + assert.match(lookup.header.title.content, /调用工具.* lookup .*失败<\/font>/); const literal = lookup.elements[0].text; assert.equal(literal.tag, "plain_text"); assert.match(literal.content, /invoice/); assert.match(literal.content, /permission denied/); assert.match(literal.content, //); - assert.match(pwd.header.title.content, /运行.*`pwd`.*0\.4s/); + assert.match(pwd.header.title.content, /运行.* pwd .*0\.4s/); assert.match(pwd.elements[0].text.content, /退出码\n0/); - assert.match(read.header.title.content, /调用工具.*`docs · read`.*未完成/); + assert.match(read.header.title.content, /调用工具.* docs · read .*未完成/); }); test("resumed questions display only the current exchange, including on completion", async () => { @@ -2001,8 +1999,8 @@ test("a running card shows the latest four steps and folds the earlier ones", as ]); const card = JSON.parse(formatTurnCard("in_progress", items)); const [earlier, rule, ...recent] = card.body.elements; - const label = (row) => row.tag === "markdown" ? row.content : row.header.title.content.match(/`(.*)`/)[1]; - assert.equal(card.header.subtitle.content, "正在运行 · 8 步"); + const label = (row) => row.tag === "markdown" ? row.content : row.header.title.content.match(/(step \d)/)[1]; + assert.equal(card.header.title.content, "处理中 · 正在运行 · 8 步"); assert.equal(earlier.tag, "collapsible_panel"); assert.equal(earlier.expanded, false); assert.match(earlier.header.title.content, /更早的 4 步/); @@ -2012,3 +2010,41 @@ test("a running card shows the latest four steps and folds the earlier ones", as assert.deepEqual(recent.map(label), ["说明 3", "step 3", "说明 4", "step 4"]); assert.match(recent.at(-1).header.title.content, /进行中/); }); + +test("thinking and tool rows stay on one line while commentary keeps its lines", async () => { + const { formatTurnCard } = await import("../server/feishu.mjs"); + const long = "先确认已有的模型和 Skill,再创建环境,然后检查飞书机器人的绑定和连接状态是否正常,最后试运行"; + const card = JSON.parse(formatTurnCard("in_progress", [ + { type: "reasoning", summary: [{ text: long }] }, + { type: "command_execution", command: `for i in 1 2 3 4; do sleep 30; node parsar.mjs GET /app/runtimes/${"x".repeat(80)}/trial; done`, output: "ok", duration_ms: 128700, status: "completed" }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "第一行说明。\n第二行说明。" }] }, + ])); + const columns = (text) => [...text.replace(/<[^>]*>/g, "")].reduce((sum, char) => { + const code = char.codePointAt(0); + return sum + (code >= 0x1100 && !(code >= 0x2000 && code <= 0x206f) ? 2 : 1); + }, 0); + const [thought, run, said] = card.body.elements; + for (const row of [thought, run]) assert.ok(columns(row.header.title.content) <= 52, row.header.title.content); + assert.match(run.header.title.content, /128\.7s<\/font>$/, "the state stays on the row"); + assert.equal(said.content, "第一行说明。\n第二行说明。"); +}); + +test("no Markdown image reaches a card, since Feishu rejects the whole card for an invalid image key", async () => { + const { formatTurnCard } = await import("../server/feishu.mjs"); + const qr = "![飞书授权二维码](/app/feishu/provision/1a77dd3d-124b-44b2-8d5d-3d75f1c72e23/qrcode)"; + const items = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "给我链接吧" }] }, + { type: "reasoning", summary: [{ text: `Present the link, then ${qr}` }] }, + { type: "message", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: "图表如下 ![chart](https://example.com/chart.png)" }] }, + { type: "command_execution", command: "node parsar.mjs POST /app/feishu/provision/x", output: qr, status: "completed" }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: `链接:https://open.feishu.cn/page/launcher?user_code=AB-12\n\n${qr}` }] }, + ]; + for (const status of ["in_progress", "completed"]) { + const card = formatTurnCard(status, items); + const markdown = JSON.stringify(JSON.parse(card).body, (key, value) => key === "text" && value?.tag === "plain_text" ? undefined : value); + assert.doesNotMatch(markdown, /!\[/, `${status}: Markdown images must not reach the card`); + } + const done = JSON.parse(formatTurnCard("completed", items)).body.elements[0].content; + assert.match(done, /user_code=AB-12/, "the link the Agent gave stays in the answer"); + assert.match(done, /授权二维码见图片消息/); +}); From 837d71a035ee486974460cd0a3649bee90e38cdd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 26 Oct 2026 14:06:31 +0800 Subject: [PATCH 05/19] Show test and trial results on their buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 测试 and 试运行 rows added small status text such as "可用 · 首次回复 8 秒" beside the button. The button now shows the result itself; only a failure adds text, since it says what to fix. Attachment sizes no longer add small grey text either. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/src/Checks.tsx | 60 +++++++++---------------- example/parsar/src/components/Media.tsx | 1 - example/parsar/src/lib/en.json | 2 - example/parsar/tests/workflow.spec.ts | 10 +++++ 4 files changed, 32 insertions(+), 41 deletions(-) diff --git a/example/parsar/src/Checks.tsx b/example/parsar/src/Checks.tsx index 4ebcc1c47..01b08c2b6 100644 --- a/example/parsar/src/Checks.tsx +++ b/example/parsar/src/Checks.tsx @@ -1,10 +1,24 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { tr } from "./lib/i18n"; import { product } from "./lib/product"; +import { Check } from "lucide-react"; import { Button } from "./components/ui/button"; -import { cn } from "./lib/utils"; -const seconds = (ms: number) => (ms / 1000).toFixed(1); +// The button carries the result; only a failure adds text, since it says what to fix. +function Failure({ text }: { text?: string }) { + return text ? ( + + {text} + + ) : null; +} + +const passed = ( + <> + + {tr("可用")} + +); // One tiny generation with the saved connection, so a wrong key or model ID // shows up here instead of in a conversation. @@ -16,24 +30,14 @@ export function ModelTest({ id, name }: { id: string; name: string }) { const result = test.data; return ( <> - {result && ( - - {result.ok - ? tr("可用 · {{value0}} 秒", { value0: seconds(result.latency_ms) }) - : result.error} - - )} + ); @@ -61,38 +65,18 @@ export function RuntimeTrial({ id, name }: { id: string; name: string }) { }); const state = trial.data; const running = state?.state === "running" || start.isPending; - const text = start.error - ? (start.error as Error).message - : state?.state === "running" - ? tr("试运行中\u2026") - : state?.state === "succeeded" - ? tr("可用 · 首次回复 {{value0}} 秒", { value0: state.seconds }) - : state?.failure; return ( <> - {text && ( - - {text} - - )} + ); diff --git a/example/parsar/src/components/Media.tsx b/example/parsar/src/components/Media.tsx index 86d8e6f85..61e1ce1da 100644 --- a/example/parsar/src/components/Media.tsx +++ b/example/parsar/src/components/Media.tsx @@ -49,7 +49,6 @@ export function ArtifactAttachment({ sessionId, file, preview = true }: {
{preview && /\.(png|jpe?g|gif|webp)$/i.test(name) && } - {Math.ceil(file.size_bytes / 1024)} KiB
); diff --git a/example/parsar/src/lib/en.json b/example/parsar/src/lib/en.json index eb758b328..60d1823df 100644 --- a/example/parsar/src/lib/en.json +++ b/example/parsar/src/lib/en.json @@ -472,12 +472,10 @@ "已等待 {{value0}} 秒": "waited {{value0}} s", "通常约 {{value0}} 秒": "usually about {{value0}} s", "重试中…": "Retrying…", - "可用 · {{value0}} 秒": "Works · {{value0}} s", "测试 {{value0}}": "Test {{value0}}", "测试中…": "Testing…", "测试": "Test", "试运行中…": "Running a trial…", - "可用 · 首次回复 {{value0}} 秒": "Works · first reply in {{value0}} s", "试运行 {{value0}}": "Trial run {{value0}}", "试运行": "Trial run", "成功率": "Success rate", diff --git a/example/parsar/tests/workflow.spec.ts b/example/parsar/tests/workflow.spec.ts index 70ed5a138..22253900b 100644 --- a/example/parsar/tests/workflow.spec.ts +++ b/example/parsar/tests/workflow.spec.ts @@ -1072,3 +1072,13 @@ test("the runs view shows running conversations with their stage, stops them and await recent.getByRole("link", { name: /^Review code/ }).click(); await expect(page.getByLabel("继续对话")).toBeVisible(); }); + +test("an environment trial reports its result on the button, without extra status text", async ({ page }) => { + await resources(page); + await agent(page); + await page.goto("/#/runtimes"); + const trial = page.getByRole("button", { name: /^试运行 / }).first(); + await trial.click(); + await expect(trial).toHaveText("可用", { timeout: 30000 }); + await expect(page.getByText(/首次回复|可用 ·/)).toHaveCount(0); +}); From 7b11f73f8ca4a29f8d55a3c11bb7419ed410d117 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 26 Oct 2026 14:06:31 +0800 Subject: [PATCH 06/19] Use Core's saved Agents and Environment Templates Parsar kept its Agents and environment templates in SQLite and inlined all of their configuration into every Session request, so the example never showed the Agents API's own Agent and Environment Template resources and kept a second home for that configuration. Each Agent is now a saved Core Agent (model, instructions, tools, Harness) and each environment template a Core Environment Template (packages, setup, variables, files). Parsar writes them on save, rolls a save back when Core rejects it, deletes them with the record, rewrites them when anything they bind changes, and recreates them on next use after a Project move. A Session references both and adds only the bot variables, the Agent's Skills, Plugins and knowledge, and attachments. The browser proxy no longer exposes /v1/agents. Every capability kind (Skills, MCP, prompts, knowledge, Plugins) is one definition in capability-kinds.mjs that resolves an Agent's bindings and says where each goes, replacing the per-kind code in Session creation. Only live configuration blocks deletion: an Agent that binds an item or uses a template or model, or a bot bound to an Agent. Conversations keep their snapshot and history. A Skill an Agent binds is not deleted. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 8 +- example/parsar/README.zh.md | 10 +- example/parsar/server.mjs | 12 +- example/parsar/server/capabilities.mjs | 7 +- example/parsar/server/capability-kinds.mjs | 116 ++++++++++++ example/parsar/server/core-resources.mjs | 127 +++++++++++++ example/parsar/server/product.mjs | 68 +++---- example/parsar/server/runtime-profile.mjs | 18 -- example/parsar/server/sessions.mjs | 210 +++++---------------- example/parsar/server/trials.mjs | 4 +- example/parsar/server/turns.mjs | 1 + example/parsar/src/Records.tsx | 5 +- example/parsar/src/Runs.tsx | 9 +- example/parsar/src/lib/product.ts | 1 + example/parsar/tests/cli.test.mjs | 3 +- example/parsar/tests/core-double.mjs | 50 +++++ example/parsar/tests/feishu.test.mjs | 14 +- example/parsar/tests/fixture.mjs | 28 ++- example/parsar/tests/product.test.mjs | 156 ++++++++++++--- example/parsar/tests/server.test.mjs | 47 +++-- 20 files changed, 596 insertions(+), 298 deletions(-) create mode 100644 example/parsar/server/capability-kinds.mjs create mode 100644 example/parsar/server/core-resources.mjs create mode 100644 example/parsar/tests/core-double.mjs diff --git a/example/parsar/README.md b/example/parsar/README.md index 4ce16f1bb..fbd9c107e 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -21,11 +21,11 @@ Open http://127.0.0.1:18180. `OPENAI_BASE_URL` ends in `/v1`; remote endpoints r | Object | What it is | | --- | --- | -| **资源库** | Building blocks, grouped by what uses them: models for Agent engines; Skills, MCP servers, Plugins, knowledge and instruction fragments for Agents; credentials for MCP. Resources are shared: editing one affects everything that uses it, and an item in use cannot be removed. | +| **资源库** | Building blocks, grouped by what uses them: models for Agent engines; Skills, MCP servers, Plugins, knowledge and instruction fragments for Agents; credentials for MCP. Resources are shared: editing one affects everything that uses it. Only live configuration blocks removal: an Agent that binds an item, an Agent that uses a template or model, or a bot bound to an Agent. Conversations do not; they keep the configuration snapshot they were created with and their full history. | | **环境模板** | The prepared workspace: npm and Python packages, a Bash setup script, environment variables, Feishu bot variables and initial files. | | **Agent** | What people talk to: name, description, instructions, how it runs, one required environment template, capabilities and behavior switches. How it runs mirrors a local Codex configuration: Harness, model, reasoning effort, web search and, under **高级**, reasoning summary, verbosity and service tier. **复制** creates an independent copy. | | **飞书机器人** | One bot is bound to at most one Agent. It routes its chats to that Agent and is the Agent’s Feishu identity in every conversation, including Web chats. | -| **对话** | One public Core Session with its own Environment. It freezes the Agent and environment configuration when it starts. | +| **对话** | One public Core Session with its own Environment, created from the Agent's saved Core Agent and its environment template's Core Environment Template. Core copies both when the Session starts. | Configuration lives only in templates and Agents; a conversation never overrides it. Edits affect only new conversations. When the Agent or its environment template changes, an existing conversation says so and offers **用新配置开始新对话**. @@ -107,7 +107,7 @@ Optional business questions use the declared `ask_user` function. They appear in - **凭据** creates Vaults and write-only bearer credentials. **OAuth 授权** uses the maintained MCP SDK for discovery, dynamic client registration, issuer binding and PKCE, then stores the token through public Vault APIs. OAuth tokens never return to the browser. - Instruction fragments and knowledge have immutable numbered versions, defaults, pins and deprecation. Knowledge versions become initial workspace files. Standard Claude/Codex Plugin ZIPs become public Environment Plugins; declared `.mcp.json` servers are listed. Executables required by stdio servers must exist in the environment. -**环境模板** runs only in Core-managed workspaces. Each new conversation installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Initial files use canonical `/workspace/` paths; scripts run from the workspace. Variable values are write-only in product responses: `null` preserves an existing value, a string replaces it and removing the name deletes it. **飞书机器人变量** map variable names to the bound bot’s **App ID**, **App Secret** or region; a conversation with such a template requires the Agent to have a bound bot. Parsar sends templates inline through the public Session Environment contract and does not create a Core Template resource. Core may reuse an identical earlier preparation for up to 24 hours ([preparation reuse](../../contracts/agents-api/environments.md#preparation-reuse)), and a long-idle conversation can move to a new sandbox; in both cases only `/environment` (including `/workspace`) is kept and setup does not run again. Setup must therefore write everything it needs later under `/workspace` or the package directories, never under HOME. The Feishu workspace keeps lark-cli's configuration and encrypted credentials in `/workspace/.lark-cli` through `LARKSUITE_CLI_CONFIG_DIR` and `LARKSUITE_CLI_DATA_DIR`. +**环境模板** runs only in Core-managed workspaces. Each new conversation installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Initial files use canonical `/workspace/` paths; scripts run from the workspace. Variable values are write-only in product responses: `null` preserves an existing value, a string replaces it and removing the name deletes it. **飞书机器人变量** map variable names to the bound bot’s **App ID**, **App Secret** or region; a conversation with such a template requires the Agent to have a bound bot. Each template is saved as a Core Environment Template and each Agent as a Core Agent. Parsar rewrites them when they or anything they bind change, and recreates them on next use if the Core Project changed. A conversation's Session references both and adds only what differs per conversation: the bot variables, the Agent's Skills, Plugins and knowledge files, and attachments ([template inheritance](../../contracts/agents-api/environments.md#inheritance)). Core may reuse an identical earlier preparation for up to 24 hours ([preparation reuse](../../contracts/agents-api/environments.md#preparation-reuse)), and a long-idle conversation can move to a new sandbox; in both cases only `/environment` (including `/workspace`) is kept and setup does not run again. Setup must therefore write everything it needs later under `/workspace` or the package directories, never under HOME. The Feishu workspace keeps lark-cli's configuration and encrypted credentials in `/workspace/.lark-cli` through `LARKSUITE_CLI_CONFIG_DIR` and `LARKSUITE_CLI_DATA_DIR`. **提前准备,新对话无需等待安装** keeps one prepared Session for each Agent that uses the template. Parsar creates it without input, so Core prepares its Environment ahead of time, and the next Web or Feishu conversation takes it over with its first message; the conversation keeps its own title. A prepared Session is replaced when the Agent, its environment template, bot or model provider changes, after 12 hours, or once taken; turning the switch off discards it. A first message with images or files starts an ordinary Session. Each prepared Session holds a workspace while it waits. @@ -141,7 +141,7 @@ node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 -- ## Ownership and local API -Core’s Project key stays server-side. Successful conversation creation leaves a Core reference and product metadata; pending creation retains the frozen public request until confirmed. Core remains authoritative for execution, Items, Turns, Skills, Vaults and Artifacts. SQLite stores product configuration, capability versions, Feishu delivery state and temporary OAuth/registration state. Files live under `~/.oac/data/parsar-example/`; `OAC_EXAMPLE_DATA_DIR` overrides this with an absolute path. Core origin and Project-key fingerprint select the file. Back it up with the server stopped. Provider and bot secrets reside in it; the Core Project key does not. Builds and caches use `${OAC_DEV_HOME:-$HOME/.oac}`. +Core’s Project key stays server-side. Successful conversation creation leaves a Core reference and product metadata; pending creation retains the frozen public request until confirmed. Core remains authoritative for execution, Items, Turns, Skills, Vaults and Artifacts, and holds each Agent and environment template as a saved Agent and Environment Template written only by Parsar's server. SQLite stores product configuration, capability versions, Feishu delivery state and temporary OAuth/registration state. Files live under `~/.oac/data/parsar-example/`; `OAC_EXAMPLE_DATA_DIR` overrides this with an absolute path. Core origin and Project-key fingerprint select the file. Back it up with the server stopped. Provider and bot secrets reside in it; the Core Project key does not. Builds and caches use `${OAC_DEV_HOME:-$HOME/.oac}`. | Product route family | Caller and credential | | --- | --- | diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index df9936711..576f66666 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: 91f8e0d2abc4d4bb989358f846e6b212fbb38e764a953a1d175c17e6b3c640ea +source_hash: 62934f80a9b631d42ea26739bc2439bc11093975debcbf789fe48f567603a1fe --- # Parsar 工作空间 @@ -26,11 +26,11 @@ pnpm --filter @oac/parsar-example dev | 对象 | 含义 | | --- | --- | -| **资源库** | 组装用的零件,按使用方分组:模型供 Agent 的执行引擎使用;Skills、MCP 服务、Plugins、知识和指令片段供 Agent 使用;凭据供 MCP 使用。资源是共享的:修改一项会影响所有使用它的对象,正在使用的条目不能移除。 | +| **资源库** | 组装用的零件,按使用方分组:模型供 Agent 的执行引擎使用;Skills、MCP 服务、Plugins、知识和指令片段供 Agent 使用;凭据供 MCP 使用。资源是共享的:修改一项会影响所有使用它的对象。只有仍然有效的配置会阻止删除:Agent 绑定了这项能力、Agent 使用这个环境模板或模型,或者机器人绑定了这个 Agent。对话不算:它保留创建时的配置快照和完整历史。 | | **环境模板** | 准备好的工作区:npm 与 Python 软件包、Bash 初始化脚本、环境变量、飞书机器人变量和初始文件。 | | **Agent** | 用户对话的对象:名称、描述、指令、运行方式、一个必选的环境模板、能力和行为开关。运行方式对应本地 Codex 的配置:Harness、模型、推理强度、网页搜索,以及 **高级** 中的推理摘要、输出详细度和服务层级。**复制**会生成一份独立副本。 | | **飞书机器人** | 一个机器人最多绑定一个 Agent。它把自己的聊天路由给该 Agent,并在所有对话中(包括网页对话)作为该 Agent 的飞书身份。 | -| **对话** | 一个公开 Core Session,拥有独立的 Environment,在开始时冻结 Agent 和环境配置。 | +| **对话** | 一个公开 Core Session,拥有独立的 Environment,由 Agent 对应的 Core Agent 和环境模板对应的 Core Environment Template 创建。Session 开始时,Core 会复制这两份配置。 | 配置只在模板和 Agent 中设定,对话不会覆盖。修改只影响新对话。Agent 或其环境模板变更后,已有对话会给出提示,并提供 **用新配置开始新对话**。 @@ -112,7 +112,7 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 - **凭据** 创建 Vault 和只写的 bearer 凭据。**OAuth 授权** 使用维护中的 MCP SDK 完成发现、动态客户端注册、签发方绑定和 PKCE,再通过公开 Vault API 保存令牌。OAuth 令牌不会返回浏览器。 - 指令片段和知识具有不可变的编号版本、默认版本、固定版本和停用状态。知识版本会成为初始工作区文件。标准 Claude/Codex Plugin ZIP 会成为公开 Environment Plugin,并列出其中声明的 `.mcp.json` 服务。stdio 服务所需的可执行文件必须存在于环境中。 -**环境模板** 只在 Core 托管的工作区中运行。每个新对话会先写入文件、安装软件包并完成初始化,再启动 Agent;初始化失败会停止执行。初始文件使用规范的 `/workspace/` 路径,脚本在工作区中运行。变量值在产品响应中只写:`null` 保留已有值,字符串替换,删除名称即删除变量。**飞书机器人变量** 把变量名映射到所绑定机器人的 **App ID**、**App Secret** 或地区;使用这类模板的对话要求 Agent 已绑定机器人。Parsar 通过公开 Session Environment 合约内联发送模板,不创建 Core Template 资源。Core 可能在 24 小时内复用一次完全相同的准备结果([准备结果复用](../../contracts/agents-api/zh/environments.md#preparation-reuse)),长时间空闲的对话也可能换到新沙箱;这两种情况下只保留 `/environment`(包括 `/workspace`),初始化脚本不会再次执行。因此初始化脚本必须把之后要用的内容写到 `/workspace` 或软件包目录下,不能写在 HOME 中。飞书工作环境通过 `LARKSUITE_CLI_CONFIG_DIR` 和 `LARKSUITE_CLI_DATA_DIR` 把 lark-cli 的配置和加密凭证放在 `/workspace/.lark-cli`。 +**环境模板** 只在 Core 托管的工作区中运行。每个新对话会先写入文件、安装软件包并完成初始化,再启动 Agent;初始化失败会停止执行。初始文件使用规范的 `/workspace/` 路径,脚本在工作区中运行。变量值在产品响应中只写:`null` 保留已有值,字符串替换,删除名称即删除变量。**飞书机器人变量** 把变量名映射到所绑定机器人的 **App ID**、**App Secret** 或地区;使用这类模板的对话要求 Agent 已绑定机器人。每个环境模板保存为一个 Core Environment Template,每个 Agent 保存为一个 Core Agent。它们本身或它们绑定的内容变化时,Parsar 会更新 Core 里的这两份资源;Core Project 更换后,下次使用时会重新创建。对话的 Session 引用这两份资源,只附加每次对话不同的内容:机器人变量、Agent 的 Skills、Plugins 和知识文件,以及附件(见[模板继承](../../contracts/agents-api/environments.md#inheritance))。Core 可能在 24 小时内复用一次完全相同的准备结果([准备结果复用](../../contracts/agents-api/zh/environments.md#preparation-reuse)),长时间空闲的对话也可能换到新沙箱;这两种情况下只保留 `/environment`(包括 `/workspace`),初始化脚本不会再次执行。因此初始化脚本必须把之后要用的内容写到 `/workspace` 或软件包目录下,不能写在 HOME 中。飞书工作环境通过 `LARKSUITE_CLI_CONFIG_DIR` 和 `LARKSUITE_CLI_DATA_DIR` 把 lark-cli 的配置和加密凭证放在 `/workspace/.lark-cli`。 **提前准备,新对话无需等待安装** 会为使用该模板的每个 Agent 保留一个准备好的 Session。Parsar 在没有输入的情况下创建它,Core 会提前准备好 Environment;下一个网页或飞书对话发送第一条消息时直接接管它,并保留自己的标题。Agent、其环境模板、机器人或模型服务商变更后,或超过 12 小时、或被接管后,准备好的 Session 会被替换;关闭开关会丢弃它。第一条消息带图片或文件时,会创建普通的 Session。每个准备好的 Session 在等待期间会占用一个工作区。 @@ -146,7 +146,7 @@ node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 -- ## 数据归属与本地 API {#ownership-and-local-api} -Core 的 Project key 只保存在服务端。对话创建成功后,只保留 Core 引用和产品元数据;创建未确认时保留冻结的公开请求直到确认。Core 仍是执行、Items、Turns、Skills、Vaults 和 Artifacts 的权威来源。SQLite 保存产品配置、能力版本、飞书投递状态以及临时 OAuth/注册状态。文件位于 `~/.oac/data/parsar-example/`,可用绝对路径 `OAC_EXAMPLE_DATA_DIR` 覆盖;由 Core 地址和 Project key 指纹选择具体文件。请在服务停止时备份。Provider 和机器人密钥保存在其中,Core Project key 不在其中。构建和缓存使用 `${OAC_DEV_HOME:-$HOME/.oac}`。 +Core 的 Project key 只保存在服务端。对话创建成功后,只保留 Core 引用和产品元数据;创建未确认时保留冻结的公开请求直到确认。Core 仍是执行、Items、Turns、Skills、Vaults 和 Artifacts 的权威来源,并以保存的 Agent 和 Environment Template 存放每个 Agent 和环境模板,这两类资源只由 Parsar 服务端写入。SQLite 保存产品配置、能力版本、飞书投递状态以及临时 OAuth/注册状态。文件位于 `~/.oac/data/parsar-example/`,可用绝对路径 `OAC_EXAMPLE_DATA_DIR` 覆盖;由 Core 地址和 Project key 指纹选择具体文件。请在服务停止时备份。Provider 和机器人密钥保存在其中,Core Project key 不在其中。构建和缓存使用 `${OAC_DEV_HOME:-$HOME/.oac}`。 | 产品路由组 | 调用方与凭据 | | --- | --- | diff --git a/example/parsar/server.mjs b/example/parsar/server.mjs index bb61ecb04..ebcaa98c9 100644 --- a/example/parsar/server.mjs +++ b/example/parsar/server.mjs @@ -9,6 +9,7 @@ import { buildDirectory } from "./paths.mjs"; import { isIPv4 } from "node:net"; import { openStore, dataPath, AppError } from "./server/store.mjs"; import { productAPI } from "./server/product.mjs"; +import { boundAgents } from "./server/capability-kinds.mjs"; import { createFeishuTransport, feishuWorkers } from "./server/feishu.mjs"; const root = fileURLToPath(new URL(".", import.meta.url)); @@ -31,8 +32,6 @@ const routes = [ [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}$/, ["GET"]], [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/(items|turns)$/, ["GET"]], [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/events$/, ["GET", "POST"]], - [/^\/v1\/agents$/, ["GET", "POST"]], - [/^\/v1\/agents\/[a-f0-9-]{36}$/, ["GET", "POST"]], ]; routes.push( [ @@ -250,6 +249,10 @@ export function createHandler( "A same-origin Origin header is required for writes.", ); } + // Skills live in Core; one an Agent still binds is not deleted. + const skill = req.method === "DELETE" && url.pathname.match(/^\/v1\/skills\/(skill_[A-Za-z0-9_-]+)$/)?.[1]; + if (skill && store && boundAgents(store, "skills", skill).length) + return fail(res, 409, "Skill 仍被 Agent 使用,请先在 Agent 中解除绑定。"); const streaming = req.method === "GET" && url.pathname.endsWith("/events"); const controller = new AbortController(); @@ -420,6 +423,11 @@ if ( void productHandler.sessions .refreshWarm() .catch((error) => console.error("Prepared sessions:", error.message)); + // Saved Core Agents and Templates that no longer resolve, as after moving to + // a new Project, are created again on next use. + await productHandler.resources + .verify() + .catch((error) => console.error("Core resources:", error.message)); refreshPrepared(); setInterval(refreshPrepared, 15000).unref(); // Running Turns are watched for stalls and failed Environments. diff --git a/example/parsar/server/capabilities.mjs b/example/parsar/server/capabilities.mjs index 957601555..ad4fa6656 100644 --- a/example/parsar/server/capabilities.mjs +++ b/example/parsar/server/capabilities.mjs @@ -1,10 +1,11 @@ import { AppError, text, uuid } from "./store.mjs"; +import { boundAgents } from "./capability-kinds.mjs"; import { unzipSync } from "fflate"; const publicVersion = ({ archive, ...value }) => ({ ...value, has_archive: !!archive, }); -export const publicCapability = (row) => ({ +const publicCapability = (row) => ({ ...row, versions: row.versions.map(publicVersion), }); @@ -25,9 +26,7 @@ export function capabilityAPI(store) { if (old && old.revision !== body.revision) throw new AppError(409, "能力已更新,请重新加载。"); if (method === "DELETE") { - if ( - store.list("agents").some((agent) => agent.capability_ids?.includes(id)) - ) + if (boundAgents(store, "capabilities", id).length) throw new AppError(409, "能力仍被 Agent 引用。"); store.remove("capabilities", id); return {}; diff --git a/example/parsar/server/capability-kinds.mjs b/example/parsar/server/capability-kinds.mjs new file mode 100644 index 000000000..91ec022fb --- /dev/null +++ b/example/parsar/server/capability-kinds.mjs @@ -0,0 +1,116 @@ +import { AppError, text, uuid } from "./store.mjs"; + +// Every kind of capability an Agent can bind, in one place. A kind resolves an +// Agent's bindings and says where each one goes: into the Core Agent (tools, +// instructions) or into the Session's Environment (skills, plugins, files). + +const libraryEntry = (store, agent, id) => { + const capability = store.get("capabilities", id); + if (!capability || capability.deprecated) + throw new AppError(400, "Agent 绑定的能力已停用或不存在。"); + const version = capability.versions.find( + (row) => row.version === (agent.capability_versions?.[id] || capability.default_version), + ); + if (!version) throw new AppError(400, "Agent 绑定的能力版本不存在。"); + return { ...capability, ...version }; +}; +const library = (kind, contribute) => ({ + bound: (agent) => agent.capability_ids || [], + resolve: (store, agent) => + (agent.capability_ids || []) + .map((id) => libraryEntry(store, agent, id)) + .filter((row) => row.kind === kind), + contribute, +}); + +export const capabilityKinds = { + skills: { + bound: (agent) => agent.skill_ids || [], + // Skills live in Core; the binding names a Skill and optionally a version. + resolve: (_store, agent) => + (agent.skill_ids || []).map((skill_id) => ({ skill_id, version: agent.skill_versions?.[skill_id] })), + contribute: (entry, out) => + out.session.skills.push({ type: "skill_reference", skill_id: entry.skill_id, ...(entry.version ? { version: entry.version } : {}) }), + }, + mcps: { + bound: (agent) => agent.mcp_ids || [], + resolve: (store, agent) => { + const mcps = (agent.mcp_ids || []).map((id) => store.get("mcps", id)); + if (mcps.some((mcp) => !mcp)) throw new AppError(400, "Agent 绑定的资源已不可用。"); + if (new Set(mcps.map((mcp) => mcp.label)).size !== mcps.length) + throw new AppError(400, "绑定的 MCP 服务标识不能重复。"); + return mcps; + }, + contribute: (mcp, out, agent) => { + out.agent.tools.push({ + type: "mcp", + server_label: mcp.label, + transport: { type: "http", server_url: mcp.url }, + connection_origin: "environment", + // MiniMax Code admits only optional MCP initialization. + required: agent.harness !== "mcode", + ...(mcp.credential_id ? { credential_id: mcp.credential_id } : {}), + }); + if (mcp.vault_id) out.session.vault_ids.push(mcp.vault_id); + }, + // An HTTPS address without credentials; authentication comes from a Vault credential. + validate: (body) => { + let url; + try { + url = new URL(body.url); + } catch { + throw new AppError(400, "请填写有效的 MCP URL。"); + } + if (url.protocol !== "https:" || url.username || url.password || url.hash || url.search) + throw new AppError(400, "MCP 使用不含凭据的 HTTPS 地址。"); + if (body.vault_id && !uuid.test(body.vault_id)) throw new AppError(400, "凭据库标识无效。"); + if (body.credential_id && (!body.vault_id || !uuid.test(body.credential_id))) + throw new AppError(400, "凭据标识无效。"); + const label = text(body.label, "MCP 标识", 64, true); + if (!/^[a-zA-Z0-9_-]+$/.test(label)) + throw new AppError(400, "MCP 标识仅支持字母、数字、下划线和连字符。"); + return { url: url.href, vault_id: body.vault_id || "", credential_id: body.credential_id || "", label }; + }, + }, + prompt: library("prompt", (row, out) => out.agent.instructions.push(row.content)), + knowledge: library("knowledge", (row, out) => { + out.agent.instructions.push(`Knowledge: ${row.name} — ./knowledge/${row.id}.md`); + out.session.files.push({ + type: "inline", + path: `/workspace/knowledge/${row.id}.md`, + data: Buffer.from(row.content).toString("base64"), + }); + }), + plugin: library("plugin", (row, out) => + out.session.plugins.push({ + type: "inline", + name: row.name, + description: row.description, + source: { type: "base64", media_type: "application/zip", data: row.archive }, + }), + ), +}; + +// What an Agent's bindings add to its Core Agent and to each of its Sessions. +export function contributions(store, agent) { + const out = { + agent: { tools: [], instructions: [] }, + session: { skills: [], plugins: [], files: [], vault_ids: [] }, + summary: { skills: [], mcps: [], capabilities: [] }, + }; + for (const [kind, definition] of Object.entries(capabilityKinds)) + for (const entry of definition.resolve(store, agent)) { + definition.contribute(entry, out, agent); + if (kind === "skills") out.summary.skills.push({ id: entry.skill_id, version: entry.version || null }); + else if (kind === "mcps") out.summary.mcps.push(entry.label); + else out.summary.capabilities.push({ name: entry.name, kind: entry.kind, version: entry.version }); + } + out.session.vault_ids = [...new Set(out.session.vault_ids)]; + return out; +} + +// Agents that bind a capability; a bound capability cannot be deleted. +export function boundAgents(store, kind, id) { + const definition = kind === "capabilities" ? capabilityKinds.prompt : capabilityKinds[kind]; + return store.list("agents").filter((agent) => definition.bound(agent).includes(id)); +} diff --git a/example/parsar/server/core-resources.mjs b/example/parsar/server/core-resources.mjs new file mode 100644 index 000000000..90badaeeb --- /dev/null +++ b/example/parsar/server/core-resources.mjs @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { AppError } from "./store.mjs"; +import { contributions } from "./capability-kinds.mjs"; +import { harnessOptions } from "../shared/harness-options.mjs"; + +// Parsar authors Agents and environment templates; Core keeps them as saved +// Agents and Environment Templates, which Sessions reference by ID. Each Core +// resource is the projection of a Parsar record and of what it binds, and is +// rewritten whenever that projection's digest changes. + +const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); +const withoutNulls = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null)); + +const askUserTool = { + type: "function", + name: "ask_user", + description: + "Ask the user a business question when essential information is missing. Never request execution permission or approval; execution is bypass-only.", + parameters: { + type: "object", + properties: { question: { type: "string" }, options: { type: "array", items: { type: "string" } } }, + required: ["question", "options"], + additionalProperties: false, + }, +}; + +// The saved Core Agent: model, instructions, tools and Harness. Cleared +// settings are sent as null so an update resets them. +function coreAgent(store, agent) { + const model = store.get("models", agent.model_id); + if (!model || model.enabled === false) throw new AppError(400, "Agent 绑定的资源已不可用。"); + if (!harnessOptions[agent.harness]) throw new AppError(400, "请选择执行引擎。"); + const bound = contributions(store, agent); + const reasoning = agent.reasoning_effort || agent.reasoning_summary + ? { + ...(agent.reasoning_effort ? { effort: agent.reasoning_effort } : {}), + ...(agent.reasoning_summary ? { summary: agent.reasoning_summary } : {}), + } + : null; + return { + name: agent.name.slice(0, 128), + model: model.model, + instructions: [agent.instructions, ...bound.agent.instructions].filter(Boolean).join("\n\n") || null, + reasoning, + service_tier: agent.service_tier || null, + text: { verbosity: agent.verbosity || "medium" }, + tools: [ + { type: "web_search", mode: agent.web_search || "disabled" }, + ...(agent.ask_user ? [askUserTool] : []), + ...bound.agent.tools, + ], + multi_agent: { enabled: false }, + metadata: { application: "parsar-example", parsar_agent_id: agent.id }, + x_agents_core: { harness: agent.harness }, + }; +} + +export const templateFiles = (runtime) => + (runtime.files || []).map(({ path, content }) => ({ + type: "inline", + path, + data: Buffer.from(content).toString("base64"), + })); + +// The saved Core Environment Template: packages, setup, variables and files. +function coreTemplate(runtime) { + if (runtime.environment !== "openai_hosted") + throw new AppError(400, "Parsar 仅支持托管工作环境,请重新配置环境模板。"); + return { + name: runtime.name.slice(0, 256), + packages: { npm: runtime.packages?.npm || [], python: runtime.packages?.python || [] }, + setup_commands: runtime.setup_script ? [{ command: runtime.setup_script, cwd: "/workspace" }] : [], + env: { ...runtime.env }, + files: templateFiles(runtime), + }; +} + +const targets = { + agents: { field: "core_agent_id", path: "/v1/agents", body: coreAgent }, + runtimes: { field: "core_template_id", path: "/v1/agents/environments/templates", body: (_store, row) => coreTemplate(row) }, +}; + +export function coreResources(store, core) { + // Writes the Core resource when the record or anything it binds changed. + // A resource missing in Core, as after moving to a new Project, is created again. + const ensure = async (kind, id) => { + const { field, path, body: build } = targets[kind]; + const row = store.get(kind, id); + if (!row) throw new AppError(400, "请选择有效的资源。"); + const body = build(store, row); + const print = digest(body); + if (row[field] && row.core_digest === print) return row; + let coreId = row[field]; + if (coreId) + try { + await core(`${path}/${coreId}`, "POST", body); + } catch (error) { + if (!(error instanceof AppError && error.status === 404)) throw error; + coreId = undefined; + } + if (!coreId) coreId = (await core(path, "POST", withoutNulls(body))).id; + const next = { ...store.get(kind, id), [field]: coreId, core_digest: print }; + store.put(kind, next); + return next; + }; + const remove = async (kind, row) => { + const { field, path } = targets[kind]; + if (!row?.[field]) return; + await core(`${path}/${row[field]}`, "DELETE").catch((error) => { + if (!(error instanceof AppError && error.status === 404)) throw error; + }); + }; + // Forget Core IDs that no longer resolve, so the next use creates them again. + const verify = async () => { + for (const kind of Object.keys(targets)) { + const { field, path } = targets[kind]; + for (const row of store.list(kind).filter((row) => row[field])) + await core(`${path}/${row[field]}`, "GET").catch((error) => { + if (error instanceof AppError && error.status === 404) { + const { [field]: _gone, core_digest: _old, ...rest } = store.get(kind, row.id); + store.put(kind, rest); + } + }); + } + }; + return { ensure, remove, verify }; +} diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs index 3257d2fec..bb74f2569 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -8,6 +8,8 @@ import { runtimeProfile, publicRuntime } from "./runtime-profile.mjs"; import { feishuAPI } from "./feishu.mjs"; import { trialAPI } from "./trials.mjs"; import { sessionAPI } from "./sessions.mjs"; +import { coreResources } from "./core-resources.mjs"; +import { boundAgents, capabilityKinds } from "./capability-kinds.mjs"; import { AppError, text, uuid } from "./store.mjs"; import { harnessOptions, @@ -32,7 +34,8 @@ const kinds = new Set([ "sessions", ]); export function productAPI(store, core, fetchImpl = fetch, origin) { - const sessions = sessionAPI(store, core); + const resources = coreResources(store, core); + const sessions = sessionAPI(store, core, resources); const publicRow = (kind, row) => { if (kind === "providers") return publicProvider(row); if (kind === "runtimes") return publicRuntime(row); @@ -47,7 +50,7 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { const feishu = feishuAPI(store); const registration = feishuRegistrationAPI(store, fetchImpl); const providers = providerAPI(store, fetchImpl); - const trials = trialAPI(store, core); + const trials = trialAPI(store, core, resources); const sessionAfter = (run) => async (id, body) => { await run(id, body); return publicRow("sessions", store.get("sessions", id)); @@ -201,21 +204,15 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { } const previous = store.get(kind, id); if (method === "DELETE") { + // Only live configuration blocks deletion. Conversations keep the + // snapshot they were created with, and their Core Sessions keep running. const used = store.list("feishu_bots").some((row) => row.agent_id === id) || store.list("models").some((row) => row.provider_id === id) || - store - .list("agents") - .some( - (row) => - row.model_id === id || - row.runtime_id === id || - row.mcp_ids.includes(id), - ) || - store - .list("sessions") - .some((row) => row.agent_id === id || row.runtime_id === id); + store.list("agents").some((row) => row.model_id === id || row.runtime_id === id) || + (kind === "mcps" && boundAgents(store, "mcps", id).length > 0); if (used) throw new AppError(409, "资源仍被引用,请先调整绑定。"); + if (kind === "agents" || kind === "runtimes") await resources.remove(kind, previous); store.transaction(() => { store.remove(kind, id); if (kind === "models") touchProviders(previous?.provider_id); @@ -264,35 +261,7 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { ) throw new AppError(409, "此 Provider 已有相同的模型 ID。"); } - if (kind === "mcps") { - let url; - try { - url = new URL(body.url); - } catch { - throw new AppError(400, "请填写有效的 MCP URL。"); - } - if ( - url.protocol !== "https:" || - url.username || - url.password || - url.hash || - url.search - ) - throw new AppError(400, "MCP 使用不含凭据的 HTTPS 地址。"); - value.url = url.href; - if (body.vault_id && !uuid.test(body.vault_id)) - throw new AppError(400, "凭据库标识无效。"); - if ( - body.credential_id && - (!body.vault_id || !uuid.test(body.credential_id)) - ) - throw new AppError(400, "凭据标识无效。"); - value.vault_id = body.vault_id || ""; - value.credential_id = body.credential_id || ""; - value.label = text(body.label, "MCP 标识", 64, true); - if (!/^[a-zA-Z0-9_-]+$/.test(value.label)) - throw new AppError(400, "MCP 标识仅支持字母、数字、下划线和连字符。"); - } + if (kind === "mcps") Object.assign(value, capabilityKinds.mcps.validate(body)); if (kind === "runtimes") { Object.assign(value, runtimeProfile(body, previous)); } @@ -380,12 +349,24 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { if (version) value.skill_versions[skillId] = version; } } + // The saved Core Agent or Template keeps its identity across edits. + for (const field of ["core_agent_id", "core_template_id", "core_digest"]) + if (previous?.[field]) value[field] = previous[field]; store.transaction(() => { store.put(kind, value); if (kind === "models") touchProviders(previous?.provider_id, value.provider_id); }); - return publicRow(kind, value); + // Core validates the Agent or Template now, so a rejected edit is not kept. + if (kind === "agents" || kind === "runtimes") + try { + await resources.ensure(kind, id); + } catch (error) { + if (previous) store.put(kind, previous); + else store.remove(kind, id); + throw error; + } + return publicRow(kind, store.get(kind, id)); }; const handler = async (method, path, body) => { const result = await handle(method, path, body); @@ -399,5 +380,6 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { return result; }; handler.sessions = sessions; + handler.resources = resources; return handler; } diff --git a/example/parsar/server/runtime-profile.mjs b/example/parsar/server/runtime-profile.mjs index bfcbc2d4f..00860288e 100644 --- a/example/parsar/server/runtime-profile.mjs +++ b/example/parsar/server/runtime-profile.mjs @@ -107,21 +107,3 @@ export function publicRuntime({ env, ...row }) { env: Object.fromEntries(Object.keys(env || {}).map((key) => [key, null])), }; } - -export function runtimeEnvironment(runtime) { - if (runtime.environment !== "openai_hosted") - throw new AppError(400, "Parsar 仅支持托管工作环境,请重新配置环境模板。"); - return { - type: "openai_hosted", - packages: runtime.packages || { npm: [], python: [] }, - env: { ...runtime.env }, - setup_commands: runtime.setup_script - ? [{ command: runtime.setup_script, cwd: "/workspace" }] - : [], - files: (runtime.files || []).map(({ path, content }) => ({ - type: "inline", - path, - data: Buffer.from(content).toString("base64"), - })), - }; -} diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs index f527e3f2c..dc43464ca 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -1,32 +1,34 @@ import { createHash, randomUUID } from "node:crypto"; -import { runtimeEnvironment } from "./runtime-profile.mjs"; import { AppError, text, uuid } from "./store.mjs"; import { harnessOptions } from "../shared/harness-options.mjs"; +import { contributions } from "./capability-kinds.mjs"; +import { templateFiles } from "./core-resources.mjs"; import { turnsAPI } from "./turns.mjs"; // A warm Session matches a cold creation only when every frozen byte except the -// first input and the per-conversation metadata is the same. -const fingerprint = ({ input, metadata, ...request }) => - createHash("sha256").update(JSON.stringify(request)).digest("hex"); +// first input and the per-conversation metadata is the same, including the +// content of the Core Agent and Template it references. +const fingerprint = ({ request: { input, metadata, ...request }, core_digests }) => + createHash("sha256").update(JSON.stringify({ request, core_digests })).digest("hex"); const warmAge = 12 * 3600_000; export function sessionBot(store, agentId) { return store.list("feishu_bots").find((bot) => bot.agent_id === agentId); } -export function prepareSession(store, id, body, origin = {}, warm = false) { - const agent = store.get("agents", body.agent_id); - if (!agent) throw new AppError(400, "请选择有效的 Agent。"); - if (agent.enabled === false) - throw new AppError(400, "Agent 已停用,请先启用。"); - const runtime = store.get("runtimes", agent.runtime_id || ""); - if (!runtime) throw new AppError(400, "Agent 未配置环境模板。"); +// A Session references the Agent's saved Core Agent and its environment +// template's Core Template, and adds only what differs per conversation: the +// bot identity, the Agent's Skills, Plugins and knowledge, and attachments. +export async function prepareSession(store, resources, id, body, origin = {}, warm = false) { + const stored = store.get("agents", body.agent_id); + if (!stored) throw new AppError(400, "请选择有效的 Agent。"); + if (stored.enabled === false) throw new AppError(400, "Agent 已停用,请先启用。"); + const storedRuntime = store.get("runtimes", stored.runtime_id || ""); + if (!storedRuntime) throw new AppError(400, "Agent 未配置环境模板。"); + const agent = await resources.ensure("agents", stored.id); + const runtime = await resources.ensure("runtimes", storedRuntime.id); const model = store.get("models", agent.model_id); - const mcps = agent.mcp_ids.map((id) => store.get("mcps", id)); - if (!model || model.enabled === false || mcps.some((mcp) => !mcp)) - throw new AppError(400, "Agent 绑定的资源已不可用。"); - if (new Set(mcps.map((mcp) => mcp.label)).size !== mcps.length) - throw new AppError(400, "绑定的 MCP 服务标识不能重复。"); + const bound = contributions(store, agent); const botEnv = Object.entries(runtime.bot_env || {}); const bot = sessionBot(store, agent.id); if (botEnv.length && !bot?.app_secret) @@ -34,60 +36,12 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { 400, "环境模板需要飞书机器人身份,请先在飞书接入中把机器人绑定到这个 Agent。", ); - const environment = runtimeEnvironment(runtime); - for (const [name, field] of botEnv) - environment.env[name] = field === "brand" ? bot.brand || "feishu" : bot[field]; const provider = store.get("providers", model.provider_id); if (!provider?.base_url?.startsWith("https://") || !provider.api_key) throw new AppError( 400, "请先在模型页面配置所选 Provider 的 HTTPS Base URL 和 API Key。", ); - const modelProvider = { - protocol: harnessOptions[agent.harness].protocol, - base_url: provider.base_url, - api_key: provider.api_key, - }; - const tools = [ - { type: "web_search", mode: agent.web_search || "disabled" }, - ]; - if (agent.ask_user) - tools.push({ - type: "function", - name: "ask_user", - description: - "Ask the user a business question when essential information is missing. Never request execution permission or approval; execution is bypass-only.", - parameters: { - type: "object", - properties: { - question: { type: "string" }, - options: { type: "array", items: { type: "string" } }, - }, - required: ["question", "options"], - additionalProperties: false, - }, - }); - tools.push( - ...mcps.map((mcp) => ({ - type: "mcp", - server_label: mcp.label, - transport: { type: "http", server_url: mcp.url }, - connection_origin: "environment", - // MiniMax Code admits only optional MCP initialization. - required: agent.harness !== "mcode", - ...(mcp.credential_id ? { credential_id: mcp.credential_id } : {}), - })), - ); - { - environment.skills = agent.skill_ids.map((skill_id) => ({ - type: "skill_reference", - skill_id, - ...(agent.skill_versions?.[skill_id] - ? { version: agent.skill_versions[skill_id] } - : {}), - })); - if (mcps.length) environment.network = { access: "enabled" }; - } const images = body.images || []; const files = body.files || []; if ( @@ -119,60 +73,18 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { ) ) throw new AppError(400, "附件格式或大小无效。"); - if (files.length && environment.type !== "openai_hosted") - throw new AppError(400, "附件需要托管运行时。"); - if (files.length) environment.files.push(...files); - const capabilities = (agent.capability_ids || []).map((id) => { - const capability = store.get("capabilities", id); - if (!capability || capability.deprecated) - throw new AppError(400, "Agent 绑定的能力已停用或不存在。"); - const version = capability.versions.find( - (row) => - row.version === - (agent.capability_versions?.[id] || capability.default_version), - ); - if (!version) throw new AppError(400, "Agent 绑定的能力版本不存在。"); - return { ...capability, ...version }; - }); - for (const capability of capabilities) { - if (capability.kind === "prompt") continue; - if (environment.type !== "openai_hosted") - throw new AppError(400, "Plugin 和知识文件需要托管运行时。"); - if (capability.kind === "knowledge") - environment.files = [ - ...(environment.files || []), - { - type: "inline", - path: `/workspace/knowledge/${capability.id}.md`, - data: Buffer.from(capability.content).toString("base64"), - }, - ]; - if (capability.kind === "plugin") - environment.plugins = [ - ...(environment.plugins || []), - { - type: "inline", - name: capability.name, - description: capability.description, - source: { - type: "base64", - media_type: "application/zip", - data: capability.archive, - }, - }, - ]; - } - const instructions = [ - agent.instructions, - ...capabilities - .filter((row) => row.kind === "prompt") - .map((row) => row.content), - ...capabilities - .filter((row) => row.kind === "knowledge") - .map((row) => `Knowledge: ${row.name} — ./knowledge/${row.id}.md`), - ] - .filter(Boolean) - .join("\n\n"); + // Session files replace the Template's set, so they include the Template's own. + const extraFiles = [...files, ...bound.session.files]; + const environment = { + type: "openai_hosted", + environment_template_id: runtime.core_template_id, + ...(botEnv.length + ? { env: Object.fromEntries(botEnv.map(([name, field]) => [name, field === "brand" ? bot.brand || "feishu" : bot[field]])) } + : {}), + ...(bound.session.skills.length ? { skills: bound.session.skills } : {}), + ...(bound.session.plugins.length ? { plugins: bound.session.plugins } : {}), + ...(extraFiles.length ? { files: [...templateFiles(runtime), ...extraFiles] } : {}), + }; const name = warm ? "" : text(body.name, "会话名称", 80, true); const input = warm ? "" : text(body.input, "消息", 100000, true); return { @@ -185,6 +97,7 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { ? { feishu_chat_type: origin.feishu_chat_type } : {}), pricing: model.pricing, + core_digests: [agent.core_digest, runtime.core_digest], configuration: { agent_name: agent.name, agent_revision: agent.revision, @@ -201,48 +114,13 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { ...botEnv.map(([name]) => name), ], file_paths: (runtime.files || []).map((file) => file.path), - skills: agent.skill_ids.map((id) => ({ - id, - version: agent.skill_versions?.[id] || null, - })), - mcps: mcps.map((mcp) => mcp.label), - capabilities: capabilities.map((row) => ({ - name: row.name, - kind: row.kind, - version: row.version, - })), + ...bound.summary, }, created_at: Math.floor(Date.now() / 1000), request: { - agent: { - model: model.model, - instructions, - ...(agent.reasoning_effort || agent.reasoning_summary - ? { - reasoning: { - ...(agent.reasoning_effort - ? { effort: agent.reasoning_effort } - : {}), - ...(agent.reasoning_summary - ? { summary: agent.reasoning_summary } - : {}), - }, - } - : {}), - ...(agent.service_tier ? { service_tier: agent.service_tier } : {}), - x_agents_core: { harness: agent.harness }, - multi_agent: { enabled: false }, - text: { verbosity: agent.verbosity || "medium" }, - tools, - }, + agent_id: agent.core_agent_id, environment, - ...(mcps.some((mcp) => mcp.vault_id) - ? { - vault_ids: [ - ...new Set(mcps.map((mcp) => mcp.vault_id).filter(Boolean)), - ], - } - : {}), + ...(bound.session.vault_ids.length ? { vault_ids: bound.session.vault_ids } : {}), ...(input ? { input: images.length @@ -261,7 +139,14 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { : input, } : {}), - x_agents_core: { model_provider: modelProvider }, + // The Provider key stays in Parsar's model settings and travels per Session. + x_agents_core: { + model_provider: { + protocol: harnessOptions[agent.harness].protocol, + base_url: provider.base_url, + api_key: provider.api_key, + }, + }, metadata: { application: "parsar-example", agent_id: agent.id, @@ -272,7 +157,7 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { }; } -export function sessionAPI(store, core) { +export function sessionAPI(store, core, resources) { const running = new Map(); const turns = turnsAPI(store, core); // Hand a prepared Session to this conversation when one matches its frozen @@ -280,7 +165,7 @@ export function sessionAPI(store, core) { // normally if the prepared one does not start. const claim = (row) => { if (typeof row.request.input !== "string") return; - const print = fingerprint(row.request); + const print = fingerprint(row); const warm = store .list("warm_sessions") .find((entry) => entry.agent_id === row.agent_id && entry.fingerprint === print); @@ -297,7 +182,7 @@ export function sessionAPI(store, core) { if (row?.pending_input) return turns.sendPrepared(row); if (row?.core_session_id) return row; if (!row) { - row = prepareSession(store, id, body, origin); + row = await prepareSession(store, resources, id, body, origin); store.put("sessions", row); const claimed = claim(row); if (claimed) return claimed; @@ -359,8 +244,7 @@ export function sessionAPI(store, core) { ) try { print = fingerprint( - prepareSession(store, randomUUID(), { agent_id: agent.id }, {}, true) - .request, + await prepareSession(store, resources, randomUUID(), { agent_id: agent.id }, {}, true), ); } catch { print = undefined; @@ -379,7 +263,7 @@ export function sessionAPI(store, core) { if (!print || entries.some((entry) => entry.fingerprint === print && now - entry.created_at <= warmAge)) continue; const id = randomUUID(); - const { request } = prepareSession(store, id, { agent_id: agent.id }, {}, true); + const { request } = await prepareSession(store, resources, id, { agent_id: agent.id }, {}, true); const session = await core("/v1/agents/sessions", "POST", { ...request, metadata: { ...request.metadata, title: "", warm: "true" }, diff --git a/example/parsar/server/trials.mjs b/example/parsar/server/trials.mjs index 89b2c9e70..e0fd9c59e 100644 --- a/example/parsar/server/trials.mjs +++ b/example/parsar/server/trials.mjs @@ -8,7 +8,7 @@ const prompt = "只回复 OK,不要调用任何工具。"; // A trial run creates one real Session for an Agent that uses the environment // template, waits for its first reply, records the outcome and deletes the Session. -export function trialAPI(store, core) { +export function trialAPI(store, core, resources) { const get = (runtimeId) => { const trial = store.get("runtime_trials", runtimeId); if (trial?.state === "running" && Date.now() - trial.started_at > limitMs + 30_000) @@ -46,7 +46,7 @@ export function trialAPI(store, core) { if (running?.state === "running") return running; const agent = store.list("agents").find((a) => a.runtime_id === runtimeId && a.enabled !== false); if (!agent) throw new AppError(400, "先把这个环境模板绑定到一个 Agent,再试运行。"); - const { request } = prepareSession(store, randomUUID(), { agent_id: agent.id, name: "试运行", input: prompt }); + const { request } = await prepareSession(store, resources, randomUUID(), { agent_id: agent.id, name: "试运行", input: prompt }); request.metadata = { ...request.metadata, application: "parsar-trial", title: "试运行" }; const started_at = Date.now(); const session = await core("/v1/agents/sessions", "POST", request, randomUUID()); diff --git a/example/parsar/server/turns.mjs b/example/parsar/server/turns.mjs index 5d53fd8f5..6c864f5ce 100644 --- a/example/parsar/server/turns.mjs +++ b/example/parsar/server/turns.mjs @@ -296,6 +296,7 @@ export function turnsAPI(store, core) { session_id: t.session_id, core_session_id: t.core_session_id, agent_id: t.agent_id, + agent_name: record.configuration?.agent_name, conversation: record.name, source: t.source, chat_type: record.feishu_chat_type, diff --git a/example/parsar/src/Records.tsx b/example/parsar/src/Records.tsx index 023e4feb2..82e837a51 100644 --- a/example/parsar/src/Records.tsx +++ b/example/parsar/src/Records.tsx @@ -302,9 +302,12 @@ export function Records({ view }: { view: "conversations" | "usage" }) { )} {data.flatMap((row) => { + // A deleted Agent keeps the name its conversation was created with. const agent = agents.data?.find((agent) => agent.id === row.record.agent_id) - ?.name || "—"; + ?.name || + row.record.configuration?.agent_name || + "—"; if (view === "usage") return [ diff --git a/example/parsar/src/Runs.tsx b/example/parsar/src/Runs.tsx index 2149106b2..55abcdec7 100644 --- a/example/parsar/src/Runs.tsx +++ b/example/parsar/src/Runs.tsx @@ -223,8 +223,9 @@ export function Runs() { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); - const name = (id: string) => - agents.data?.find((agent) => agent.id === id)?.name || "—"; + // A deleted Agent keeps the name its conversation was created with. + const name = (row: TurnRow) => + agents.data?.find((agent) => agent.id === row.agent_id)?.name || row.agent_name || "—"; const keep = (row: TurnRow) => (!agentFilter || row.agent_id === agentFilter) && (!source || row.source === source); @@ -286,7 +287,7 @@ export function Runs() { ))} @@ -306,7 +307,7 @@ export function Runs() { {recent.length ? (
{recent.map((row) => ( - + ))}
) : ( diff --git a/example/parsar/src/lib/product.ts b/example/parsar/src/lib/product.ts index a3dfd6590..9c9b9bc76 100644 --- a/example/parsar/src/lib/product.ts +++ b/example/parsar/src/lib/product.ts @@ -69,6 +69,7 @@ export interface TurnRow extends TurnSummary { session_id: string; core_session_id: string; agent_id: string; + agent_name?: string; conversation: string; source: "web" | "feishu"; chat_type?: "p2p" | "group"; diff --git a/example/parsar/tests/cli.test.mjs b/example/parsar/tests/cli.test.mjs index 5824870b7..03291e72f 100644 --- a/example/parsar/tests/cli.test.mjs +++ b/example/parsar/tests/cli.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { createParsarClient } from "../cli.mjs"; import { createHandler } from "../server.mjs"; import { openStore } from "../server/store.mjs"; +import { savedResourcesFetch } from "./core-double.mjs"; const cli = fileURLToPath(new URL('../cli.mjs', import.meta.url)); const configure = fileURLToPath(new URL('../examples/manager/configure.mjs', import.meta.url)); @@ -15,7 +16,7 @@ async function fixture(t, fetchImpl = async () => { throw new Error('Unexpected const modelId = randomUUID(); store.put('models', { id: modelId, name: 'Test model', enabled: true }); const config = { host: '127.0.0.1', port: 0, target: 'https://core.example', key: 'server-only-core-key' }; - const server = createServer(createHandler(config, { store, fetchImpl })); + const server = createServer(createHandler(config, { store, fetchImpl: savedResourcesFetch(fetchImpl) })); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); config.port = server.address().port; t.after(() => { server.closeAllConnections(); server.close(); store.close(); }); diff --git a/example/parsar/tests/core-double.mjs b/example/parsar/tests/core-double.mjs new file mode 100644 index 000000000..0686fb899 --- /dev/null +++ b/example/parsar/tests/core-double.mjs @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import { AppError } from "../server/store.mjs"; + +// Core's saved Agents and Environment Templates for tests, in front of a test's +// own Core for everything else. `saved` holds what Parsar wrote. +const resource = /^\/v1\/agents(\/environments\/templates)?(?:\/([^/?]+))?$/; +function handle(saved, path, method, body) { + const match = path.match(resource); + if (!match || ["sessions", "environments"].includes(match[2])) return; + const [, , id] = match; + if (!id && method === "POST") { + const row = { id: randomUUID(), ...body }; + saved.set(row.id, row); + return { status: 201, value: row }; + } + const row = id && saved.get(id); + if (!row) return { status: 404, value: { error: { message: "not found" } } }; + if (method === "DELETE") { + saved.delete(id); + return { status: 200, value: { id, deleted: true } }; + } + if (method === "POST") Object.assign(row, body); + return { status: 200, value: row }; +} + +export function withSavedResources(core) { + const saved = new Map(); + const wrapped = async (path, method, body, key) => { + const result = handle(saved, path, method, body); + if (!result) return core(path, method, body, key); + if (result.status >= 400) throw new AppError(result.status, result.value.error.message); + return result.value; + }; + wrapped.saved = saved; + // The saved Core Agent a Session request references. + wrapped.agentOf = (request) => saved.get(request.agent_id); + return wrapped; +} + +export function savedResourcesFetch(fetchImpl) { + const saved = new Map(); + const wrapped = async (url, init = {}) => { + const { pathname } = new URL(url); + if (!resource.test(pathname)) return fetchImpl(url, init); + const result = handle(saved, pathname, init.method || "GET", init.body ? JSON.parse(init.body) : undefined); + return result ? Response.json(result.value, { status: result.status }) : fetchImpl(url, init); + }; + wrapped.saved = saved; + return wrapped; +} diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index 447c14bc4..8b191195c 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { openStore, AppError } from "../server/store.mjs"; import { productAPI } from "../server/product.mjs"; +import { withSavedResources } from "./core-double.mjs"; import { feishuOperations } from "../server/feishu.mjs"; import { feishuWorker as workerForBot } from "../server/feishu.mjs"; @@ -49,7 +50,8 @@ async function setup(t) { if (path.includes("/artifacts")) return { data: [], has_more: false }; return { id, status: resource.status }; }; - const api = productAPI(store, core); + const savedCore = withSavedResources(core); + const api = productAPI(store, savedCore); const put = (kind, body, id = randomUUID()) => api("PUT", `/app/${kind}/${id}`, body); const provider = await put("providers", { @@ -77,7 +79,7 @@ async function setup(t) { }); return { store, - core, + core: savedCore, api, put, agent, @@ -581,7 +583,7 @@ test("Feishu can withdraw an unstarted conversation and recover a lost creation ); }); -test("capability versions freeze into dispatch and retired capabilities cannot start new work", async (t) => { +test("pinned capability versions reach the saved Agent and retired capabilities cannot start new work", async (t) => { const f = await setup(t), id = randomUUID(); const cap = await f.api("PUT", `/app/capabilities/${id}`, { @@ -609,8 +611,10 @@ test("capability versions freeze into dispatch and retired capabilities cannot s agent_id: agent.id, input: "Work", }); - assert.match(f.calls[0].body.agent.instructions, /Always verify/); - assert.doesNotMatch(f.calls[0].body.agent.instructions, /Updated rules/); + // A pinned version reaches the saved Core Agent the Session references. + const saved = f.core.agentOf(f.calls[0].body); + assert.match(saved.instructions, /Always verify/); + assert.doesNotMatch(saved.instructions, /Updated rules/); const current = await f.api("GET", `/app/capabilities/${id}`); await f.api("PUT", `/app/capabilities/${id}`, { ...current, diff --git a/example/parsar/tests/fixture.mjs b/example/parsar/tests/fixture.mjs index 927ff156f..e76fe7a52 100644 --- a/example/parsar/tests/fixture.mjs +++ b/example/parsar/tests/fixture.mjs @@ -115,7 +115,7 @@ createServer(async (req, res) => { executorConnected = true; return reply({}); } - if (/^\/v1\/agents\/environments\/[^/]+$/.test(url.pathname)) { + if (/^\/v1\/agents\/environments\/(?!templates$)[^/]+$/.test(url.pathname)) { return reply({ id: url.pathname.split("/").at(-1), object: "agent.environment", @@ -255,7 +255,11 @@ createServer(async (req, res) => { agents = agents.filter((row) => row !== agent); return reply({ id: agent.id, object: "agent.deleted", deleted: true }); } - if (req.method === "POST") Object.assign(agent, body); + // A null field resets the saved Agent to its default. + if (req.method === "POST") + for (const [key, value] of Object.entries(body)) + if (value === null) delete agent[key]; + else agent[key] = value; return reply(agent); } if ( @@ -266,6 +270,15 @@ createServer(async (req, res) => { templates.push(template); return reply(template, 201); } + const template = templates.find((t) => url.pathname === `/v1/agents/environments/templates/${t.id}`); + if (template) { + if (req.method === "DELETE") { + templates = templates.filter((row) => row !== template); + return reply({ id: template.id, deleted: true }); + } + if (req.method === "POST") Object.assign(template, body); + return reply(template); + } if (url.pathname === "/v1/skills" && req.method === "GET") return reply(page(skills)); const versionMatch = url.pathname.match(/^\/v1\/skills\/([^/]+)\/versions$/); @@ -348,12 +361,21 @@ createServer(async (req, res) => { ); const key = req.headers["idempotency-key"]; if (receipts.has(key)) return reply(receipts.get(key)); + // A saved Agent supplies the configuration; an inline `agent` overrides it. + const reference = body.agent_id && agents.find((a) => a.id === body.agent_id); + if (body.agent_id && !reference) + return reply({ error: { message: "Agent not found" } }, 404); + if (body.environment.environment_template_id && !templates.some((t) => t.id === body.environment.environment_template_id)) + return reply({ error: { message: "Environment template not found" } }, 404); + // A Session's Agent snapshot carries configuration only, not the saved Agent's identity or name. + const { id: _id, object: _object, created_at: _created, updated_at: _updated, metadata: _metadata, name: _name, ...referenced } = reference || {}; const saved = { id: randomUUID(), name: null, service_tier: "auto", + ...referenced, ...body.agent, - reasoning: { effort: null, summary: null, ...body.agent?.reasoning }, + reasoning: { effort: null, summary: null, ...referenced.reasoning, ...body.agent?.reasoning }, multi_agent: { enabled: false, max_concurrent_subagents: null }, text: { format: { type: "text" }, verbosity: "medium" }, }; diff --git a/example/parsar/tests/product.test.mjs b/example/parsar/tests/product.test.mjs index 45c5c9384..beb8243c5 100644 --- a/example/parsar/tests/product.test.mjs +++ b/example/parsar/tests/product.test.mjs @@ -6,13 +6,14 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { openStore, dataPath, AppError } from "../server/store.mjs"; import { productAPI } from "../server/product.mjs"; +import { withSavedResources } from "./core-double.mjs"; test("environment templates prepare hosted Sessions, protect variables and freeze retries", async (t) => { const store = openStore(":memory:"); t.after(() => store.close()); let failed = true; const calls = []; - const { api, put, agent: base } = await setup( + const { api, put, core, agent: base } = await setup( store, async (_path, _method, body) => { calls.push(structuredClone(body)); @@ -48,14 +49,15 @@ test("environment templates prepare hosted Sessions, protect variables and freez ), /lost response/, ); + // The saved Core Template holds the preparation; the Session references it. const frozen = calls[0]; assert.equal(frozen.environment.type, "openai_hosted"); - assert.equal(frozen.environment.env.OFFICE_TOKEN, "private-env"); - assert.deepEqual(frozen.environment.packages, runtime.packages); - assert.equal( - frozen.environment.setup_commands[0].command, - runtime.setup_script, - ); + const template = core.saved.get(frozen.environment.environment_template_id); + assert.equal(template.env.OFFICE_TOKEN, "private-env"); + assert.deepEqual(template.packages, runtime.packages); + assert.equal(template.setup_commands[0].command, runtime.setup_script); + assert.ok(!JSON.stringify(frozen).includes("private-env"), "variables stay in the Template"); + // Session files replace the Template's set, so they carry the Template's own too. assert.deepEqual( frozen.environment.files.map((f) => f.path), ["/workspace/config.txt", "/workspace/inputs/input.txt"], @@ -205,7 +207,8 @@ test("Provider discovery reports failures without leaking keys or upstream bodie store.close(); }); -async function setup(store, core, fetchImpl) { +async function setup(store, test, fetchImpl) { + const core = withSavedResources(test); const api = productAPI(store, core, fetchImpl); const put = (kind, body, id = randomUUID()) => api("PUT", `/app/${kind}/${id}`, body); @@ -240,6 +243,7 @@ async function setup(store, core, fetchImpl) { return { api, put, + core, model, runtime, agent, @@ -250,7 +254,7 @@ async function setup(store, core, fetchImpl) { }, }; } -test("one Agent creates independent Sessions; edits do not mutate prior execution; retries survive restart", async (t) => { +test("Sessions reference one saved Core Agent that edits update in place; retries survive restart", async (t) => { const root = join(homedir(), ".oac", "tests"); await mkdir(root, { recursive: true }); const dir = await mkdtemp(join(root, "parsar-store-")); @@ -269,7 +273,7 @@ test("one Agent creates independent Sessions; edits do not mutate prior executio } return receipts.get(key); }; - const { api, put, agent, model, input } = await setup(store, core); + const { api, put, core: saved, agent, model, input } = await setup(store, core); assert.equal(calls.length, 0); const first = await put("sessions", input); assert.deepEqual(calls[0].body.environment.skills, [ @@ -281,21 +285,23 @@ test("one Agent creates independent Sessions; edits do not mutate prior executio api_key: "provider-secret", }); assert.equal(JSON.stringify(first).includes("provider-secret"), false); - assert.equal(calls[0].body.agent.tools.length, 2); - assert.deepEqual(calls[0].body.agent.tools[1], { + assert.equal(calls[0].body.agent, undefined, "the Session references the saved Agent"); + const savedAgent = saved.agentOf(calls[0].body); + assert.equal(savedAgent.tools.length, 2); + assert.deepEqual(savedAgent.tools[1], { type: "mcp", server_label: "docs", transport: { type: "http", server_url: "https://mcp.example/docs" }, connection_origin: "environment", required: true, }); - await put("agents", { ...agent, instructions: "Updated" }, agent.id); + assert.equal(savedAgent.instructions, "Review carefully"); + await put("agents", { ...store.get("agents", agent.id), instructions: "Updated" }, agent.id); const second = await put("sessions", input); assert.notEqual(first.core_session_id, second.core_session_id); - assert.equal(calls[0].body.agent.instructions, "Review carefully"); - assert.equal(calls[1].body.agent.instructions, "Updated"); + assert.equal(calls[1].body.agent_id, calls[0].body.agent_id, "an edit updates the same saved Agent"); + assert.equal(saved.agentOf(calls[1].body).instructions, "Updated"); await assert.rejects(api("DELETE", `/app/models/${model.id}`), /仍被引用/); - await assert.rejects(api("DELETE", `/app/agents/${agent.id}`), /仍被引用/); const pending = randomUUID(); lose = true; await assert.rejects(put("sessions", input, pending), /lost response/); @@ -303,7 +309,7 @@ test("one Agent creates independent Sessions; edits do not mutate prior executio store.close(); store = openStore(path); t.after(() => store.close()); - const resumed = await productAPI(store, core)( + const resumed = await productAPI(store, saved)( "PUT", `/app/sessions/${pending}`, {}, @@ -672,7 +678,7 @@ test("the Agent's bound Feishu bot supplies mapped identity variables without ex const store = openStore(":memory:"); t.after(() => store.close()); const calls = []; - const { api, put, agent, input } = await setup(store, async (_p, _m, body) => { + const { api, put, core, agent, input } = await setup(store, async (_p, _m, body) => { calls.push(body); return { id: randomUUID() }; }); @@ -710,12 +716,13 @@ test("the Agent's bound Feishu bot supplies mapped identity variables without ex /已绑定另一个机器人/, ); const record = await put("sessions", input); + // The Template keeps its own variables; each Session adds the bot's identity on top. assert.deepEqual(calls.at(-1).environment.env, { - LARKSUITE_CLI_DEFAULT_AS: "bot", FEISHU_APP_ID: "cli_0123456789abcdef", FEISHU_APP_SECRET: "bot-secret", FEISHU_BRAND: "lark", }); + assert.deepEqual(core.saved.get(calls.at(-1).environment.environment_template_id).env, { LARKSUITE_CLI_DEFAULT_AS: "bot" }); assert.equal(record.configuration.bot_name, "Assistant"); assert.ok(record.configuration.env_names.includes("FEISHU_APP_SECRET")); assert.ok(!JSON.stringify(await api("GET", "/app/sessions")).includes("bot-secret")); @@ -724,11 +731,11 @@ test("the Agent's bound Feishu bot supplies mapped identity variables without ex await assert.rejects(api("DELETE", `/app/agents/${agent.id}`), /仍被引用/); }); -test("Agents carry Harness options into the Session request and reject undeclared values", async (t) => { +test("Agents carry Harness options into the saved Core Agent and reject undeclared values", async (t) => { const store = openStore(":memory:"); t.after(() => store.close()); const sent = []; - const { api, put, agent, input } = await setup(store, async (_path, _method, body) => { + const { api, put, core, agent, input } = await setup(store, async (_path, _method, body) => { sent.push(body); return { id: randomUUID() }; }); @@ -747,12 +754,13 @@ test("Agents carry Harness options into the Session request and reject undeclare ); await put("sessions", input); const request = sent[0]; - assert.deepEqual(request.agent.x_agents_core, { harness: "codex" }); - assert.deepEqual(request.agent.reasoning, { effort: "high", summary: "auto" }); - assert.deepEqual(request.agent.text, { verbosity: "low" }); - assert.equal(request.agent.service_tier, "priority"); + const saved = core.agentOf(request); + assert.deepEqual(saved.x_agents_core, { harness: "codex" }); + assert.deepEqual(saved.reasoning, { effort: "high", summary: "auto" }); + assert.deepEqual(saved.text, { verbosity: "low" }); + assert.equal(saved.service_tier, "priority"); assert.deepEqual( - request.agent.tools.find((tool) => tool.type === "web_search"), + saved.tools.find((tool) => tool.type === "web_search"), { type: "web_search", mode: "live" }, ); assert.equal(request.x_agents_core.model_provider.protocol, "responses"); @@ -763,9 +771,10 @@ test("Agents carry Harness options into the Session request and reject undeclare ); assert.equal(plain.web_search, "disabled"); await put("sessions", { ...input, name: "Plain" }); - assert.equal(sent[1].agent.reasoning, undefined); - assert.equal(sent[1].agent.service_tier, undefined); - assert.deepEqual(sent[1].agent.text, { verbosity: "medium" }); + // Cleared settings reset the saved Agent instead of keeping old values. + assert.equal(core.agentOf(sent[1]).reasoning, null); + assert.equal(core.agentOf(sent[1]).service_tier, null); + assert.deepEqual(core.agentOf(sent[1]).text, { verbosity: "medium" }); assert.equal(sent[1].x_agents_core.model_provider.protocol, "anthropic"); for (const [patch, message] of [ [{ reasoning_summary: "auto" }, /Claude Code 不支持所选的推理摘要/], @@ -853,7 +862,7 @@ test("Agent resources follow Core's Environment limits instead of one shared bud const make = (kind, n) => Array.from({ length: n }, (_, i) => { const id = randomUUID(); - store.put("capabilities", { id, kind, name: `${kind} ${i}`, default_version: "1", versions: [{ version: "1" }] }); + store.put("capabilities", { id, kind, name: `${kind} ${i}`, default_version: "1", versions: [{ version: "1", content: "x", archive: "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==" }] }); return id; }); const prompts = make("prompt", 12), @@ -1115,3 +1124,90 @@ test("the run overview lists running and recent Turns with their stage, counts a assert.equal(overview.stats.concurrency.length, 31); assert.equal(overview.stats.concurrency.at(-1), 1); }); + +test("Agents and templates live in Core as saved resources that follow every change", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const sent = []; + let rejectAgents = false; + const { api, put, core, agent, runtime, input } = await setup(store, async (_path, _method, body) => { + sent.push(body); + return { id: randomUUID() }; + }); + const coreAgentId = store.get("agents", agent.id).core_agent_id; + const coreTemplateId = store.get("runtimes", runtime.id).core_template_id; + assert.equal(core.saved.get(coreAgentId).metadata.parsar_agent_id, agent.id); + assert.ok(core.saved.has(coreTemplateId)); + + // An edit updates the same saved Agent. + await put("agents", { ...store.get("agents", agent.id), instructions: "Edited" }, agent.id); + assert.equal(store.get("agents", agent.id).core_agent_id, coreAgentId); + assert.equal(core.saved.get(coreAgentId).instructions, "Edited"); + + // An edit Core rejects is not kept. + const rejecting = async (path, method, body, key) => { + if (rejectAgents && path.startsWith("/v1/agents/") && method === "POST") throw new AppError(400, "invalid agent"); + return core(path, method, body, key); + }; + const guarded = productAPI(store, rejecting); + rejectAgents = true; + await assert.rejects( + guarded("PUT", `/app/agents/${agent.id}`, { ...store.get("agents", agent.id), instructions: "Rejected" }), + /invalid agent/, + ); + assert.equal(store.get("agents", agent.id).instructions, "Edited"); + rejectAgents = false; + + // What an Agent binds reaches its saved Agent when the next Session starts. + const mcpId = store.get("agents", agent.id).mcp_ids[0]; + await put("mcps", { ...store.get("mcps", mcpId), url: "https://mcp.example/v2" }, mcpId); + await put("sessions", input); + assert.equal(sent.at(-1).agent_id, coreAgentId); + assert.equal(sent.at(-1).environment.environment_template_id, coreTemplateId); + assert.equal(core.saved.get(coreAgentId).tools.find((tool) => tool.type === "mcp").transport.server_url, "https://mcp.example/v2"); + + // A new Project loses the saved resources; the next use creates them again. + core.saved.clear(); + await api.resources.verify(); + assert.equal(store.get("agents", agent.id).core_agent_id, undefined); + await put("sessions", { ...input, name: "After move" }); + assert.ok(core.saved.has(sent.at(-1).agent_id)); + assert.ok(core.saved.has(sent.at(-1).environment.environment_template_id)); + + // Deleting a template deletes its saved Core Template. + const spare = await put("runtimes", { name: "Spare" }); + const spareTemplate = store.get("runtimes", spare.id).core_template_id; + await api("DELETE", `/app/runtimes/${spare.id}`, {}); + assert.equal(core.saved.has(spareTemplate), false); +}); + +test("only live configuration blocks deletion; conversations keep their snapshot and history", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + const calls = []; + const { api, put, core, agent, runtime, model, input } = await setup(store, async (path, method, body) => { + calls.push({ path, method }); + return { id: randomUUID() }; + }); + const conversation = await put("sessions", input); + // An Agent that uses a template, model or MCP keeps them. + await assert.rejects(api("DELETE", `/app/runtimes/${runtime.id}`), /仍被引用/); + await assert.rejects(api("DELETE", `/app/models/${model.id}`), /仍被引用/); + await assert.rejects(api("DELETE", `/app/mcps/${agent.mcp_ids[0]}`), /仍被引用/); + // A bot bound to the Agent keeps it. + store.put("feishu_bots", { id: randomUUID(), agent_id: agent.id }); + await assert.rejects(api("DELETE", `/app/agents/${agent.id}`), /仍被引用/); + store.remove("feishu_bots", store.list("feishu_bots")[0].id); + // A conversation is history, not a dependency. + const coreAgentId = store.get("agents", agent.id).core_agent_id; + await api("DELETE", `/app/agents/${agent.id}`, {}); + assert.equal(core.saved.has(coreAgentId), false); + await api("DELETE", `/app/runtimes/${runtime.id}`, {}); + await api("DELETE", `/app/mcps/${agent.mcp_ids[0]}`, {}); + await api("DELETE", `/app/models/${model.id}`, {}); + const kept = await api("GET", `/app/sessions/${conversation.id}`); + assert.equal(kept.core_session_id, conversation.core_session_id); + assert.equal(kept.configuration.agent_name, "Reviewer"); + assert.ok((await api("GET", "/app/sessions")).some((row) => row.id === conversation.id)); + assert.equal(calls.some((call) => call.method === "DELETE" && call.path.includes("/sessions/")), false, "its Core Session and messages stay"); +}); diff --git a/example/parsar/tests/server.test.mjs b/example/parsar/tests/server.test.mjs index 3c7cf36f3..0c676a67f 100644 --- a/example/parsar/tests/server.test.mjs +++ b/example/parsar/tests/server.test.mjs @@ -1,7 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createServer, request } from "node:http"; +import { randomUUID } from "node:crypto"; import { configuration, createHandler } from "../server.mjs"; +import { openStore } from "../server/store.mjs"; test("oversized writes return a definite 413 without contacting Core", async (t) => { let called = false; @@ -9,7 +11,7 @@ test("oversized writes return a definite 413 without contacting Core", async (t) called = true; return Response.json({}); }); - const response = await fetch(`${url}/v1/agents`, { + const response = await fetch(`${url}/v1/vaults`, { method: "POST", headers: { origin: url }, body: "x".repeat(1024 * 1024 + 1), @@ -18,14 +20,14 @@ test("oversized writes return a definite 413 without contacting Core", async (t) assert.equal(called, false); }); -async function serve(t, fetchImpl, host) { +async function serve(t, fetchImpl, host, store) { const config = { port: 0, key: "server-only-secret", target: "https://core.example", ...(host ? { host } : {}), }; - const server = createServer(createHandler(config, { fetchImpl })); + const server = createServer(createHandler(config, { fetchImpl, store })); await new Promise((done) => server.listen(0, "127.0.0.1", done)); config.port = server.address().port; t.after(() => { @@ -39,7 +41,7 @@ test("server injects only the project key and preserves request identity", async let called = false; const url = await serve(t, async (target, init) => { called = true; - assert.equal(target, "https://core.example/v1/agents"); + assert.equal(target, "https://core.example/v1/vaults"); assert.equal(init.headers.Authorization, "Bearer server-only-secret"); assert.equal(init.headers["Idempotency-Key"], "same-operation"); assert.equal(init.headers["OpenAI-Beta"], "agents=v1"); @@ -47,7 +49,7 @@ test("server injects only the project key and preserves request identity", async assert.equal(init.headers.cookie, undefined); return Response.json({ id: "session" }, { status: 201 }); }); - const result = await fetch(`${url}/v1/agents`, { + const result = await fetch(`${url}/v1/vaults`, { method: "POST", body: "{}", headers: { @@ -67,14 +69,17 @@ test("cross-origin, rebinding, management and encoded paths never reach Core", a throw new Error("must not reach Core"); }); for (const [path, headers, method, status] of [ - ["/v1/agents", { origin: "https://evil.example" }, "GET", 403], - ["/v1/agents", { host: "evil.example" }, "GET", 403], - ["/v1/agents", { "sec-fetch-site": "cross-site" }, "GET", 403], - ["/v1/agents", {}, "POST", 403], + ["/v1/vaults", { origin: "https://evil.example" }, "GET", 403], + ["/v1/vaults", { host: "evil.example" }, "GET", 403], + ["/v1/vaults", { "sec-fetch-site": "cross-site" }, "GET", 403], + ["/v1/vaults", {}, "POST", 403], ["/core/v1/projects", {}, "GET", 404], ["/api/v1/devices", {}, "GET", 404], ["/v1/agents%2fsessions", {}, "GET", 404], - ["/v1/agents", {}, "DELETE", 404], + ["/v1/vaults", {}, "DELETE", 404], + // Saved Agents and Templates are written only by Parsar's server. + ["/v1/agents", {}, "GET", 404], + ["/v1/agents/environments/templates", {}, "GET", 404], ]) { const received = await new Promise((done, reject) => { const req = request(`${url}${path}`, { method, headers }, (res) => { @@ -104,7 +109,7 @@ test("redirects and transport errors do not expose credentials or follow another }, ]) { const url = await serve(t, reply); - const result = await fetch(`${url}/v1/agents`); + const result = await fetch(`${url}/v1/vaults`); assert.equal(result.status, 502); assert.equal((await result.text()).includes("server-only-secret"), false); } @@ -359,7 +364,7 @@ test("loopback port forwarding preserves exact-origin writes and rejects non-loo ]) { const actual = await new Promise((resolve, reject) => { const req = request( - `${url}/v1/agents`, + `${url}/v1/vaults`, { method: "POST", headers: { host, ...(origin ? { origin } : {}) } }, (res) => { res.resume(); @@ -391,7 +396,7 @@ test("a configured LAN address is the only accepted Host", async (t) => { ]) { const actual = await new Promise((resolve, reject) => { const req = request( - `${url}/v1/agents`, + `${url}/v1/vaults`, { method: "POST", headers: { host, origin: `http://${host}` } }, (res) => { res.resume(); @@ -404,3 +409,19 @@ test("a configured LAN address is the only accepted Host", async (t) => { assert.equal(actual, status, host); } }); + +test("a Skill an Agent binds is not deleted", async (t) => { + const store = openStore(":memory:"); + t.after(() => store.close()); + store.put("agents", { id: randomUUID(), skill_ids: ["skill_bound"] }); + const deleted = []; + const url = await serve(t, async (target) => { + deleted.push(String(target)); + return Response.json({ id: "skill_free", deleted: true }); + }, undefined, store); + const remove = (id) => fetch(`${url}/v1/skills/${id}`, { method: "DELETE", headers: { Origin: url } }); + assert.equal((await remove("skill_bound")).status, 409); + assert.equal(deleted.length, 0); + assert.equal((await remove("skill_free")).status, 200); + assert.equal(deleted.length, 1); +}); From aaaf6dcca8d56a2d13e722ff5ee4e5637be1f95a Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 26 Oct 2026 15:15:22 +0800 Subject: [PATCH 07/19] Unify the capability library and give the bound bot's identity to every conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library listed each capability kind on its own page, credentials were bound to MCP servers for every Agent alike, and a bot's identity reached a conversation only when its environment template mapped the bot's fields to variables, which templates easily missed: an Agent with a bound bot then ran without identity and tried to register a new app. 资源库 now has 能力, 模型 and 凭据. 能力 lists every Skill, MCP, Plugin, instruction fragment and knowledge item with its version, enabled Agents and the credentials it needs, and 新建能力 adds any kind. Skills, Plugins and environment templates declare the variables they need; each Agent chooses a secret (凭据 → 密钥, write-only) for each, and a Vault credential for each MCP marked as needing one. An Agent cannot be saved with a credential missing, and choices nothing needs are dropped. A bound bot's identity reaches every conversation of its Agent as FEISHU_APP_ID, FEISHU_APP_SECRET and FEISHU_BRAND; only the bot supplies these names. Declaring them marks a need for a bot. The template mapping and its editor are removed. A bot takes the name its app has in Feishu when it connects. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 18 +- example/parsar/README.zh.md | 20 +- .../parsar/examples/feishu/environment.json | 6 +- example/parsar/examples/manager/GUIDE.md | 6 +- example/parsar/server/capabilities.mjs | 4 +- example/parsar/server/capability-kinds.mjs | 91 ++++- example/parsar/server/feishu.mjs | 9 +- example/parsar/server/product.mjs | 51 ++- example/parsar/server/runtime-profile.mjs | 26 +- example/parsar/server/sessions.mjs | 28 +- example/parsar/shared/bot-bindings.d.mts | 2 + example/parsar/shared/bot-bindings.mjs | 11 +- example/parsar/src/AgentPage.tsx | 1 + example/parsar/src/App.tsx | 6 +- example/parsar/src/Capabilities.tsx | 172 +++------- example/parsar/src/ConfigurationFields.tsx | 211 ++++++++---- example/parsar/src/Credentials.tsx | 158 ++++++++- example/parsar/src/EnvironmentFields.tsx | 87 +---- example/parsar/src/Feishu.tsx | 18 - example/parsar/src/Library.tsx | 316 ++++++++++++++++++ example/parsar/src/Resources.tsx | 246 +++----------- example/parsar/src/Skills.tsx | 132 ++------ example/parsar/src/lib/en.json | 61 ++-- example/parsar/src/lib/product.ts | 22 +- example/parsar/src/lib/usage.ts | 17 +- example/parsar/tests/feishu.test.mjs | 14 + example/parsar/tests/product.test.mjs | 83 ++++- example/parsar/tests/workflow.spec.ts | 65 +++- 28 files changed, 1154 insertions(+), 727 deletions(-) create mode 100644 example/parsar/shared/bot-bindings.d.mts create mode 100644 example/parsar/src/Library.tsx diff --git a/example/parsar/README.md b/example/parsar/README.md index fbd9c107e..1f789952f 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -22,7 +22,7 @@ Open http://127.0.0.1:18180. `OPENAI_BASE_URL` ends in `/v1`; remote endpoints r | Object | What it is | | --- | --- | | **资源库** | Building blocks, grouped by what uses them: models for Agent engines; Skills, MCP servers, Plugins, knowledge and instruction fragments for Agents; credentials for MCP. Resources are shared: editing one affects everything that uses it. Only live configuration blocks removal: an Agent that binds an item, an Agent that uses a template or model, or a bot bound to an Agent. Conversations do not; they keep the configuration snapshot they were created with and their full history. | -| **环境模板** | The prepared workspace: npm and Python packages, a Bash setup script, environment variables, Feishu bot variables and initial files. | +| **环境模板** | The prepared workspace: npm and Python packages, a Bash setup script, environment variables, the secrets it needs and initial files. | | **Agent** | What people talk to: name, description, instructions, how it runs, one required environment template, capabilities and behavior switches. How it runs mirrors a local Codex configuration: Harness, model, reasoning effort, web search and, under **高级**, reasoning summary, verbosity and service tier. **复制** creates an independent copy. | | **飞书机器人** | One bot is bound to at most one Agent. It routes its chats to that Agent and is the Agent’s Feishu identity in every conversation, including Web chats. | | **对话** | One public Core Session with its own Environment, created from the Agent's saved Core Agent and its environment template's Core Environment Template. Core copies both when the Session starts. | @@ -102,12 +102,16 @@ Optional business questions use the declared `ask_user` function. They appear in **模型** groups model IDs under Providers. Fetch a Provider’s OpenAI-shaped `GET /v1/models` catalog or enter IDs manually. Keys remain server-side; blank edits preserve the saved key. **测试** sends one tiny generation with the saved connection and shows the latency or the provider's error, never the key. Discovery sends only that Provider’s key and never follows redirects. Every Harness uses the selected HTTPS Provider: Responses for Codex, Anthropic for Claude Code and MiniMax Code. Unsupported combinations fail explicitly; consult the [Harness coverage ledger](../../contracts/agents-api/index.md). -- **Skills** supports SKILL.md creation, ZIP upload, versions, default selection, per-Agent version pins and bounded file previews. **浏览目录** searches the skills.sh index; previews use the pinned `skills@1.5.23` CLI in a temporary directory and the normal public Skill upload. This requires `npx` and network access on the Parsar host. Imported archives are bounded to 32 MiB and 1,000 files. -- **MCP** provides a static service directory and HTTPS endpoint configuration. Optional Vault credentials use official public MCP tools with environment origin. -- **凭据** creates Vaults and write-only bearer credentials. **OAuth 授权** uses the maintained MCP SDK for discovery, dynamic client registration, issuer binding and PKCE, then stores the token through public Vault APIs. OAuth tokens never return to the browser. +**资源库** has three tabs: **能力**, **模型** and **凭据**. + +- **能力** lists every capability Agents can bind in one table, with its type, version, the number of enabled Agents that bind it and the credentials it needs; filters narrow it to Skill, MCP, Plugin, 指令片段 or 知识. **新建能力** adds any kind: an HTTP MCP, one from the MCP directory, a written or uploaded Skill, one from the Skill directory, a Plugin, an instruction fragment or knowledge. +- **Skills** have versions, default selection, per-Agent version pins and bounded file previews. The Skill directory searches the skills.sh index; previews use the pinned `skills@1.5.23` CLI in a temporary directory and the normal public Skill upload, which requires `npx` and network access on the Parsar host. Imported archives are bounded to 32 MiB and 1,000 files. +- **MCP** servers are HTTPS endpoints using official public MCP tools with environment origin. One marked **需要凭据** uses the Vault credential each Agent that binds it chooses. - Instruction fragments and knowledge have immutable numbered versions, defaults, pins and deprecation. Knowledge versions become initial workspace files. Standard Claude/Codex Plugin ZIPs become public Environment Plugins; declared `.mcp.json` servers are listed. Executables required by stdio servers must exist in the environment. +- A Skill or Plugin can list the variables it needs (**需要的密钥**). Each Agent that binds it chooses a secret for each in its **凭据** section, and a conversation receives the values as environment variables. An Agent cannot be saved until every credential its capabilities need is chosen; a choice nothing needs any more is dropped. +- **凭据** holds **密钥**, write-only values for those variables, and **MCP 凭据**: Vaults with write-only bearer credentials. **OAuth 授权** uses the maintained MCP SDK for discovery, dynamic client registration, issuer binding and PKCE, then stores the token through public Vault APIs. Secret values and OAuth tokens never return to the browser. -**环境模板** runs only in Core-managed workspaces. Each new conversation installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Initial files use canonical `/workspace/` paths; scripts run from the workspace. Variable values are write-only in product responses: `null` preserves an existing value, a string replaces it and removing the name deletes it. **飞书机器人变量** map variable names to the bound bot’s **App ID**, **App Secret** or region; a conversation with such a template requires the Agent to have a bound bot. Each template is saved as a Core Environment Template and each Agent as a Core Agent. Parsar rewrites them when they or anything they bind change, and recreates them on next use if the Core Project changed. A conversation's Session references both and adds only what differs per conversation: the bot variables, the Agent's Skills, Plugins and knowledge files, and attachments ([template inheritance](../../contracts/agents-api/environments.md#inheritance)). Core may reuse an identical earlier preparation for up to 24 hours ([preparation reuse](../../contracts/agents-api/environments.md#preparation-reuse)), and a long-idle conversation can move to a new sandbox; in both cases only `/environment` (including `/workspace`) is kept and setup does not run again. Setup must therefore write everything it needs later under `/workspace` or the package directories, never under HOME. The Feishu workspace keeps lark-cli's configuration and encrypted credentials in `/workspace/.lark-cli` through `LARKSUITE_CLI_CONFIG_DIR` and `LARKSUITE_CLI_DATA_DIR`. +**环境模板** runs only in Core-managed workspaces. Each new conversation installs files and packages and completes setup before starting the Agent; a setup failure stops execution. Initial files use canonical `/workspace/` paths; scripts run from the workspace. Variable values are write-only in product responses: `null` preserves an existing value, a string replaces it and removing the name deletes it. **需要的密钥** lists variables the Agent supplies, as for Skills and Plugins. Each template is saved as a Core Environment Template and each Agent as a Core Agent. Parsar rewrites them when they or anything they bind change, and recreates them on next use if the Core Project changed. A conversation's Session references both and adds only what differs per conversation: the bot variables, the Agent's Skills, Plugins and knowledge files, and attachments ([template inheritance](../../contracts/agents-api/environments.md#inheritance)). Core may reuse an identical earlier preparation for up to 24 hours ([preparation reuse](../../contracts/agents-api/environments.md#preparation-reuse)), and a long-idle conversation can move to a new sandbox; in both cases only `/environment` (including `/workspace`) is kept and setup does not run again. Setup must therefore write everything it needs later under `/workspace` or the package directories, never under HOME. The Feishu workspace keeps lark-cli's configuration and encrypted credentials in `/workspace/.lark-cli` through `LARKSUITE_CLI_CONFIG_DIR` and `LARKSUITE_CLI_DATA_DIR`. **提前准备,新对话无需等待安装** keeps one prepared Session for each Agent that uses the template. Parsar creates it without input, so Core prepares its Environment ahead of time, and the next Web or Feishu conversation takes it over with its first message; the conversation keeps its own title. A prepared Session is replaced when the Agent, its environment template, bot or model provider changes, after 12 hours, or once taken; turning the switch off discards it. A first message with images or files starts an ordinary Session. Each prepared Session holds a workspace while it waits. @@ -127,11 +131,11 @@ Accepted messages are committed to SQLite before the WebSocket ACK; request IDs ### Bot identity -The Agent acts as its bound bot. With **飞书机器人变量**, Parsar puts the bot’s App ID and App Secret into the conversation’s Environment variables. The Feishu case maps them to `FEISHU_*` names and its setup script runs `lark-cli config init --app-secret-stdin`, so the CLI mints and refreshes tenant tokens itself and long conversations keep working. Do not map them to `LARKSUITE_CLI_APP_ID` or `LARKSUITE_CLI_APP_SECRET`: those switch lark-cli to environment credentials, which require a ready-made tenant token. Anyone who can talk to the bot can use the bot’s permissions, and tools in the Environment can read the secret. Grant the bot only the scopes its Agent needs and prefer one bot per Agent. A rotated secret applies to new conversations only. +The Agent acts as its bound bot. Every conversation of an Agent with a bound bot, Web ones included, receives the bot’s identity as `FEISHU_APP_ID`, `FEISHU_APP_SECRET` and `FEISHU_BRAND`; nothing needs to be mapped. A template, Skill or Plugin that needs the bot lists these names under **需要的密钥**: the library shows **飞书机器人** as its requirement, the Agent page shows the bot that provides it, and an Agent without a bot cannot be saved. Only the bound bot supplies these names. The Feishu case's setup script runs `lark-cli config init --app-secret-stdin`, so the CLI mints and refreshes tenant tokens itself and long conversations keep working. Do not pass them as `LARKSUITE_CLI_APP_ID` or `LARKSUITE_CLI_APP_SECRET`: those switch lark-cli to environment credentials, which require a ready-made tenant token. Anyone who can talk to the bot can use the bot’s permissions, and tools in the Environment can read the secret. Grant the bot only the scopes its Agent needs and prefer one bot per Agent. A rotated secret applies to new conversations only. ### Feishu workspace case -[The Feishu workspace](examples/feishu/) is assembled from ordinary templates. [environment.json](examples/feishu/environment.json) declares packages, bot variables and files, [setup.sh](examples/feishu/setup.sh) installs the checksum-verified official CLI binary and configures it with the bot credentials, [agent.json](examples/feishu/agent.json) defines a Codex Agent. Configure a model, import the official `lark-shared` Skill and the business Skills you need, add a bot, then run: +[The Feishu workspace](examples/feishu/) is assembled from ordinary templates. [environment.json](examples/feishu/environment.json) declares packages, the bot identity it needs and files, [setup.sh](examples/feishu/setup.sh) installs the checksum-verified official CLI binary and configures it with the bot credentials, [agent.json](examples/feishu/agent.json) defines a Codex Agent. Configure a model, import the official `lark-shared` Skill and the business Skills you need, add a bot, then run: ```bash node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --bot BOT_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index 576f66666..c3b3331ed 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: 62934f80a9b631d42ea26739bc2439bc11093975debcbf789fe48f567603a1fe +source_hash: bf6d042037c030463b3ad1723b7d8789edd471c4f1f3b96ef3f11843ca6fa158 --- # Parsar 工作空间 @@ -27,7 +27,7 @@ pnpm --filter @oac/parsar-example dev | 对象 | 含义 | | --- | --- | | **资源库** | 组装用的零件,按使用方分组:模型供 Agent 的执行引擎使用;Skills、MCP 服务、Plugins、知识和指令片段供 Agent 使用;凭据供 MCP 使用。资源是共享的:修改一项会影响所有使用它的对象。只有仍然有效的配置会阻止删除:Agent 绑定了这项能力、Agent 使用这个环境模板或模型,或者机器人绑定了这个 Agent。对话不算:它保留创建时的配置快照和完整历史。 | -| **环境模板** | 准备好的工作区:npm 与 Python 软件包、Bash 初始化脚本、环境变量、飞书机器人变量和初始文件。 | +| **环境模板** | 准备好的工作区:npm 与 Python 软件包、Bash 初始化脚本、环境变量、需要的密钥和初始文件。 | | **Agent** | 用户对话的对象:名称、描述、指令、运行方式、一个必选的环境模板、能力和行为开关。运行方式对应本地 Codex 的配置:Harness、模型、推理强度、网页搜索,以及 **高级** 中的推理摘要、输出详细度和服务层级。**复制**会生成一份独立副本。 | | **飞书机器人** | 一个机器人最多绑定一个 Agent。它把自己的聊天路由给该 Agent,并在所有对话中(包括网页对话)作为该 Agent 的飞书身份。 | | **对话** | 一个公开 Core Session,拥有独立的 Environment,由 Agent 对应的 Core Agent 和环境模板对应的 Core Environment Template 创建。Session 开始时,Core 会复制这两份配置。 | @@ -107,12 +107,16 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 **模型** 按 Provider 分组管理模型 ID。可获取 Provider 的 OpenAI 格式 `GET /v1/models` 目录,也可手动填写。密钥只保存在服务端,留空编辑会保留已保存的密钥。**测试** 会用已保存的连接发起一次极小的生成,显示耗时或服务商返回的错误,不会显示密钥。获取目录时只发送该 Provider 的密钥,且不跟随重定向。所有 Harness 都使用所选的 HTTPS Provider:Codex 使用 Responses 协议,Claude Code 和 MiniMax Code 使用 Anthropic 协议。不支持的组合会明确失败,请参阅 [Harness 覆盖记录](../../contracts/agents-api/zh/index.md)。 -- **Skills** 支持创建 SKILL.md、上传 ZIP、版本管理、默认版本选择、按 Agent 固定版本以及有限的文件预览。**浏览目录** 搜索 skills.sh 索引;预览在临时目录中使用固定版本的 `skills@1.5.23` CLI,再走常规公开 Skill 上传。这需要 Parsar 主机具备 `npx` 和网络访问。导入的压缩包限制为 32 MiB 和 1,000 个文件。 -- **MCP** 提供静态服务目录和 HTTPS 端点配置。可选的 Vault 凭据使用官方公开 MCP 工具,连接来源为 environment。 -- **凭据** 创建 Vault 和只写的 bearer 凭据。**OAuth 授权** 使用维护中的 MCP SDK 完成发现、动态客户端注册、签发方绑定和 PKCE,再通过公开 Vault API 保存令牌。OAuth 令牌不会返回浏览器。 +**资源库** 有三个标签页:**能力**、**模型** 和 **凭据**。 + +- **能力** 把 Agent 可以绑定的所有能力放在一张表里,显示类型、版本、绑定它的已启用 Agent 数量和它需要的凭据;可以按 Skill、MCP、Plugin、指令片段或知识筛选。**新建能力** 可以添加任意一种:HTTP MCP、从 MCP 目录添加、编写或上传 Skill、从 Skill 目录导入、Plugin、指令片段或知识。 +- **Skills** 支持版本管理、默认版本选择、按 Agent 固定版本以及有限的文件预览。Skill 目录搜索 skills.sh 索引;预览在临时目录中使用固定版本的 `skills@1.5.23` CLI,再走常规公开 Skill 上传,这需要 Parsar 主机具备 `npx` 和网络访问。导入的压缩包限制为 32 MiB 和 1,000 个文件。 +- **MCP** 服务是 HTTPS 端点,使用官方公开 MCP 工具,连接来源为 environment。标记为 **需要凭据** 的 MCP,使用绑定它的每个 Agent 各自选择的 Vault 凭据。 - 指令片段和知识具有不可变的编号版本、默认版本、固定版本和停用状态。知识版本会成为初始工作区文件。标准 Claude/Codex Plugin ZIP 会成为公开 Environment Plugin,并列出其中声明的 `.mcp.json` 服务。stdio 服务所需的可执行文件必须存在于环境中。 +- Skill 或 Plugin 可以列出它需要的变量(**需要的密钥**)。绑定它的每个 Agent 在自己的 **凭据** 部分为每个变量选择一个密钥,对话以环境变量的形式拿到这些值。能力需要的凭据没有全部选好之前,Agent 不能保存;不再需要的选择会被自动去掉。 +- **凭据** 包括 **密钥**(这些变量的只写值)和 **MCP 凭据**(Vault 及其中只写的 bearer 凭据)。**OAuth 授权** 使用维护中的 MCP SDK 完成发现、动态客户端注册、签发方绑定和 PKCE,再通过公开 Vault API 保存令牌。密钥的值和 OAuth 令牌都不会返回浏览器。 -**环境模板** 只在 Core 托管的工作区中运行。每个新对话会先写入文件、安装软件包并完成初始化,再启动 Agent;初始化失败会停止执行。初始文件使用规范的 `/workspace/` 路径,脚本在工作区中运行。变量值在产品响应中只写:`null` 保留已有值,字符串替换,删除名称即删除变量。**飞书机器人变量** 把变量名映射到所绑定机器人的 **App ID**、**App Secret** 或地区;使用这类模板的对话要求 Agent 已绑定机器人。每个环境模板保存为一个 Core Environment Template,每个 Agent 保存为一个 Core Agent。它们本身或它们绑定的内容变化时,Parsar 会更新 Core 里的这两份资源;Core Project 更换后,下次使用时会重新创建。对话的 Session 引用这两份资源,只附加每次对话不同的内容:机器人变量、Agent 的 Skills、Plugins 和知识文件,以及附件(见[模板继承](../../contracts/agents-api/environments.md#inheritance))。Core 可能在 24 小时内复用一次完全相同的准备结果([准备结果复用](../../contracts/agents-api/zh/environments.md#preparation-reuse)),长时间空闲的对话也可能换到新沙箱;这两种情况下只保留 `/environment`(包括 `/workspace`),初始化脚本不会再次执行。因此初始化脚本必须把之后要用的内容写到 `/workspace` 或软件包目录下,不能写在 HOME 中。飞书工作环境通过 `LARKSUITE_CLI_CONFIG_DIR` 和 `LARKSUITE_CLI_DATA_DIR` 把 lark-cli 的配置和加密凭证放在 `/workspace/.lark-cli`。 +**环境模板** 只在 Core 托管的工作区中运行。每个新对话会先写入文件、安装软件包并完成初始化,再启动 Agent;初始化失败会停止执行。初始文件使用规范的 `/workspace/` 路径,脚本在工作区中运行。变量值在产品响应中只写:`null` 保留已有值,字符串替换,删除名称即删除变量。**需要的密钥** 列出由 Agent 提供的变量,和 Skill、Plugin 的写法相同。每个环境模板保存为一个 Core Environment Template,每个 Agent 保存为一个 Core Agent。它们本身或它们绑定的内容变化时,Parsar 会更新 Core 里的这两份资源;Core Project 更换后,下次使用时会重新创建。对话的 Session 引用这两份资源,只附加每次对话不同的内容:机器人变量、Agent 的 Skills、Plugins 和知识文件,以及附件(见[模板继承](../../contracts/agents-api/environments.md#inheritance))。Core 可能在 24 小时内复用一次完全相同的准备结果([准备结果复用](../../contracts/agents-api/zh/environments.md#preparation-reuse)),长时间空闲的对话也可能换到新沙箱;这两种情况下只保留 `/environment`(包括 `/workspace`),初始化脚本不会再次执行。因此初始化脚本必须把之后要用的内容写到 `/workspace` 或软件包目录下,不能写在 HOME 中。飞书工作环境通过 `LARKSUITE_CLI_CONFIG_DIR` 和 `LARKSUITE_CLI_DATA_DIR` 把 lark-cli 的配置和加密凭证放在 `/workspace/.lark-cli`。 **提前准备,新对话无需等待安装** 会为使用该模板的每个 Agent 保留一个准备好的 Session。Parsar 在没有输入的情况下创建它,Core 会提前准备好 Environment;下一个网页或飞书对话发送第一条消息时直接接管它,并保留自己的标题。Agent、其环境模板、机器人或模型服务商变更后,或超过 12 小时、或被接管后,准备好的 Session 会被替换;关闭开关会丢弃它。第一条消息带图片或文件时,会创建普通的 Session。每个准备好的 Session 在等待期间会占用一个工作区。 @@ -132,11 +136,11 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 ### 机器人身份 {#bot-identity} -Agent 以绑定的机器人身份工作。使用 **飞书机器人变量** 时,Parsar 会把机器人的 App ID 和 App Secret 写入对话 Environment 的变量。飞书案例把它们映射为 `FEISHU_*` 变量,初始化脚本运行 `lark-cli config init --app-secret-stdin`,由 CLI 自行获取并刷新租户令牌,长对话也能持续工作。不要映射为 `LARKSUITE_CLI_APP_ID` 或 `LARKSUITE_CLI_APP_SECRET`:这两个变量会让 lark-cli 进入环境凭据模式,该模式要求直接提供租户令牌。任何能与机器人对话的人都能使用机器人的权限,Environment 中的工具也能读取该密钥。只为机器人授予其 Agent 需要的权限,尽量一个 Agent 使用一个机器人。轮换后的密钥只对新对话生效。 +Agent 以绑定的机器人身份工作。绑定了机器人的 Agent,它的每个对话(包括网页对话)都会以 `FEISHU_APP_ID`、`FEISHU_APP_SECRET` 和 `FEISHU_BRAND` 拿到机器人身份,不需要任何映射。需要机器人身份的模板、Skill 或 Plugin 在 **需要的密钥** 里列出这几个名字:能力列表里它的凭据需求显示为 **飞书机器人**,Agent 页面显示由哪个机器人提供,没有绑定机器人的 Agent 不能保存。这几个名字只能由绑定的机器人提供。飞书案例的初始化脚本运行 `lark-cli config init --app-secret-stdin`,由 CLI 自行获取并刷新租户令牌,长对话也能持续工作。不要以 `LARKSUITE_CLI_APP_ID` 或 `LARKSUITE_CLI_APP_SECRET` 传入:这两个变量会让 lark-cli 进入环境凭据模式,该模式要求直接提供租户令牌。任何能与机器人对话的人都能使用机器人的权限,Environment 中的工具也能读取该密钥。只为机器人授予其 Agent 需要的权限,尽量一个 Agent 使用一个机器人。轮换后的密钥只对新对话生效。 ### 飞书工作环境案例 {#feishu-workspace-case} -[飞书工作环境](examples/feishu/)由普通模板组装而成。[environment.json](examples/feishu/environment.json) 声明软件包、机器人变量和文件,[setup.sh](examples/feishu/setup.sh) 安装经过校验和验证的官方 CLI 并用机器人凭证完成配置,[agent.json](examples/feishu/agent.json) 定义一个 Codex Agent。配置好模型,导入官方 `lark-shared` Skill 和需要的业务 Skill,添加机器人后运行: +[飞书工作环境](examples/feishu/)由普通模板组装而成。[environment.json](examples/feishu/environment.json) 声明软件包、需要的机器人身份和文件,[setup.sh](examples/feishu/setup.sh) 安装经过校验和验证的官方 CLI 并用机器人凭证完成配置,[agent.json](examples/feishu/agent.json) 定义一个 Codex Agent。配置好模型,导入官方 `lark-shared` Skill 和需要的业务 Skill,添加机器人后运行: ```bash node example/parsar/examples/feishu/configure.mjs --url http://localhost:3001 --model MODEL_ID --bot BOT_ID --skills SHARED_SKILL_ID,DOMAIN_SKILL_ID diff --git a/example/parsar/examples/feishu/environment.json b/example/parsar/examples/feishu/environment.json index 4f25681b6..765ae449b 100644 --- a/example/parsar/examples/feishu/environment.json +++ b/example/parsar/examples/feishu/environment.json @@ -12,11 +12,7 @@ "LARKSUITE_CLI_CONFIG_DIR": "/workspace/.lark-cli", "LARKSUITE_CLI_DATA_DIR": "/workspace/.lark-cli/data" }, - "bot_env": { - "FEISHU_APP_ID": "app_id", - "FEISHU_APP_SECRET": "app_secret", - "FEISHU_BRAND": "brand" - }, + "credentials": ["FEISHU_APP_ID", "FEISHU_APP_SECRET"], "files": [ { "path": "/workspace/WORKSPACE.md", "content": "# 飞书工作环境\n\n官方 lark-cli 已在 Agent 启动前安装,以 Agent 绑定的飞书机器人身份运行。禁止输出 App Secret、令牌或环境变量。读取 Skill 后使用具体业务命令。网络错误与权限不足分别报告。产物写到 ./outputs。\n" } ] diff --git a/example/parsar/examples/manager/GUIDE.md b/example/parsar/examples/manager/GUIDE.md index 16b7b1d0d..6645af40a 100644 --- a/example/parsar/examples/manager/GUIDE.md +++ b/example/parsar/examples/manager/GUIDE.md @@ -40,7 +40,7 @@ Write a JSON file, then call `PUT /app/runtimes/ --file environment.j "packages": { "npm": [], "python": ["openpyxl"] }, "setup_script": "mkdir -p /workspace/outputs", "env": {}, - "bot_env": {}, + "credentials": [], "files": [], "prepare": false } @@ -48,7 +48,7 @@ Write a JSON file, then call `PUT /app/runtimes/ --file environment.j Use only needed packages and files. Initial files are `{ "path": "/workspace/name", "content": "text" }`. Setup runs in the hosted workspace before the Agent starts; it is executable code, so review it as part of the requested configuration. Keep persistent files under `/workspace`; HOME is not persistent. Environment preparation may be reused. Never put the management CLI or its address in ordinary business templates unless the user explicitly asks for management access. -Environment variable values are write-only: GET returns `null`, and PUT with `null` keeps the saved value. Retain untouched names. Removing a name removes its value. Do not ask users to paste secrets into chat; direct them to the existing configuration UI. A template with `bot_env` needs an Agent bound to a Feishu bot before it can run. +Environment variable values are write-only: GET returns `null`, and PUT with `null` keeps the saved value. Retain untouched names. Removing a name removes its value. Do not ask users to paste secrets into chat; direct them to the existing configuration UI. `credentials` lists variables the Agent must supply. `FEISHU_APP_ID`, `FEISHU_APP_SECRET` and `FEISHU_BRAND` come from the Agent's bound Feishu bot, so list them when setup or a capability needs the bot's identity; an Agent without a bot cannot then be saved. Any other name is supplied from a secret the Agent chooses in its `credentials` map (name to `/app/secrets` ID). Skills (`PUT /app/skills//credentials`) and Plugins declare the same list. ## Create an Agent @@ -80,7 +80,7 @@ When a user asks to open or connect a Feishu bot, guide the whole setup rather t Read `/app/agents`, `/app/runtimes` and `/app/feishu/bots`. Identify the intended business Agent from the user's goal; if it does not exist, create it using the workflow above. Do not bind the management Agent unless the user explicitly wants management access through that bot. Each bot binds one Agent and each Agent binds at most one bot. Reuse the intended Agent's existing bot; do not replace another binding, rotate credentials or create a duplicate to resolve a conflict. Ask only for unresolved choices such as which Agent or whether to use an existing Feishu application. -For a new setup, default to private chat only and tell the user. Enable group chat only when requested: it permits replies to mentions in any group the bot joins, not a list of selected group IDs. Preserve the chat choices of an existing bot unless the user asks to change them. Binding a bot supplies that bot's identity to new Agent conversations, including Web conversations when the environment maps `bot_env`; do not copy credentials into files or ordinary Agent instructions. +For a new setup, default to private chat only and tell the user. Enable group chat only when requested: it permits replies to mentions in any group the bot joins, not a list of selected group IDs. Preserve the chat choices of an existing bot unless the user asks to change them. Binding a bot supplies that bot's identity as `FEISHU_APP_ID`, `FEISHU_APP_SECRET` and `FEISHU_BRAND` to every new conversation of its Agent, Web ones included; do not copy credentials into files or ordinary Agent instructions. ### Register a new application diff --git a/example/parsar/server/capabilities.mjs b/example/parsar/server/capabilities.mjs index ad4fa6656..92dc25ef5 100644 --- a/example/parsar/server/capabilities.mjs +++ b/example/parsar/server/capabilities.mjs @@ -1,5 +1,5 @@ import { AppError, text, uuid } from "./store.mjs"; -import { boundAgents } from "./capability-kinds.mjs"; +import { boundAgents, credentialNames } from "./capability-kinds.mjs"; import { unzipSync } from "fflate"; const publicVersion = ({ archive, ...value }) => ({ ...value, @@ -106,6 +106,8 @@ export function capabilityAPI(store) { kind, versions, default_version, + // A Plugin's programs may read variables the Agent supplies from its secrets. + credentials: kind === "plugin" ? credentialNames(body.credentials ?? old?.credentials) : [], deprecated: body.deprecated === true, revision: (old?.revision || 0) + 1, }; diff --git a/example/parsar/server/capability-kinds.mjs b/example/parsar/server/capability-kinds.mjs index 91ec022fb..20b3a5c0b 100644 --- a/example/parsar/server/capability-kinds.mjs +++ b/example/parsar/server/capability-kinds.mjs @@ -1,8 +1,18 @@ -import { AppError, text, uuid } from "./store.mjs"; +import { AppError, text } from "./store.mjs"; +import { botVariables, environmentVariableName } from "../shared/bot-bindings.mjs"; // Every kind of capability an Agent can bind, in one place. A kind resolves an -// Agent's bindings and says where each one goes: into the Core Agent (tools, -// instructions) or into the Session's Environment (skills, plugins, files). +// Agent's bindings, says which credentials each needs, and where each one goes: +// into the Core Agent (tools, instructions) or into the Session's Environment +// (skills, plugins, files, variables). + +// Variable names a capability needs the Agent to supply from a secret. +export function credentialNames(value) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 20 || value.some((name) => !environmentVariableName(name))) + throw new AppError(400, "凭据需求是有效的环境变量名,最多 20 个,且不能使用运行环境保留的名称。"); + return [...new Set(value)]; +} const libraryEntry = (store, agent, id) => { const capability = store.get("capabilities", id); @@ -27,8 +37,13 @@ export const capabilityKinds = { skills: { bound: (agent) => agent.skill_ids || [], // Skills live in Core; the binding names a Skill and optionally a version. - resolve: (_store, agent) => - (agent.skill_ids || []).map((skill_id) => ({ skill_id, version: agent.skill_versions?.[skill_id] })), + // Parsar keeps only the credentials a Skill needs. + resolve: (store, agent) => + (agent.skill_ids || []).map((skill_id) => ({ + skill_id, + version: agent.skill_versions?.[skill_id], + credentials: store.get("skill_credentials", skill_id)?.credentials || [], + })), contribute: (entry, out) => out.session.skills.push({ type: "skill_reference", skill_id: entry.skill_id, ...(entry.version ? { version: entry.version } : {}) }), }, @@ -41,7 +56,11 @@ export const capabilityKinds = { throw new AppError(400, "绑定的 MCP 服务标识不能重复。"); return mcps; }, + // An MCP that needs authentication uses the Vault credential the Agent chose for it. contribute: (mcp, out, agent) => { + const credential = mcp.requires_credential && agent.mcp_credentials?.[mcp.id]; + if (mcp.requires_credential && !credential) + throw new AppError(400, `请在 Agent 中为 MCP「${mcp.name}」选择凭据。`); out.agent.tools.push({ type: "mcp", server_label: mcp.label, @@ -49,9 +68,9 @@ export const capabilityKinds = { connection_origin: "environment", // MiniMax Code admits only optional MCP initialization. required: agent.harness !== "mcode", - ...(mcp.credential_id ? { credential_id: mcp.credential_id } : {}), + ...(credential ? { credential_id: credential.credential_id } : {}), }); - if (mcp.vault_id) out.session.vault_ids.push(mcp.vault_id); + if (credential) out.session.vault_ids.push(credential.vault_id); }, // An HTTPS address without credentials; authentication comes from a Vault credential. validate: (body) => { @@ -63,13 +82,10 @@ export const capabilityKinds = { } if (url.protocol !== "https:" || url.username || url.password || url.hash || url.search) throw new AppError(400, "MCP 使用不含凭据的 HTTPS 地址。"); - if (body.vault_id && !uuid.test(body.vault_id)) throw new AppError(400, "凭据库标识无效。"); - if (body.credential_id && (!body.vault_id || !uuid.test(body.credential_id))) - throw new AppError(400, "凭据标识无效。"); const label = text(body.label, "MCP 标识", 64, true); if (!/^[a-zA-Z0-9_-]+$/.test(label)) throw new AppError(400, "MCP 标识仅支持字母、数字、下划线和连字符。"); - return { url: url.href, vault_id: body.vault_id || "", credential_id: body.credential_id || "", label }; + return { url: url.href, label, requires_credential: body.requires_credential === true }; }, }, prompt: library("prompt", (row, out) => out.agent.instructions.push(row.content)), @@ -92,23 +108,62 @@ export const capabilityKinds = { }; // What an Agent's bindings add to its Core Agent and to each of its Sessions. +export const boundBot = (store, agent) => + store.list("feishu_bots").find((bot) => bot.agent_id === agent.id && bot.app_secret); + +// What needs credentials: every bound capability and the Agent's environment template. +function needers(store, agent) { + const out = []; + for (const [kind, definition] of Object.entries(capabilityKinds)) + for (const entry of definition.resolve(store, agent)) out.push({ kind, entry }); + const runtime = store.get("runtimes", agent.runtime_id || ""); + if (runtime) out.push({ kind: "runtimes", entry: runtime }); + return out; +} + +// What an Agent's bindings add to its Core Agent and to each of its Sessions. +// A bound bot's identity reaches every conversation; any other variable comes +// from the secret the Agent chose for it. export function contributions(store, agent) { const out = { agent: { tools: [], instructions: [] }, - session: { skills: [], plugins: [], files: [], vault_ids: [] }, + session: { skills: [], plugins: [], files: [], vault_ids: [], env: {} }, summary: { skills: [], mcps: [], capabilities: [] }, }; - for (const [kind, definition] of Object.entries(capabilityKinds)) - for (const entry of definition.resolve(store, agent)) { - definition.contribute(entry, out, agent); - if (kind === "skills") out.summary.skills.push({ id: entry.skill_id, version: entry.version || null }); - else if (kind === "mcps") out.summary.mcps.push(entry.label); - else out.summary.capabilities.push({ name: entry.name, kind: entry.kind, version: entry.version }); + const bot = boundBot(store, agent); + for (const { kind, entry } of needers(store, agent)) { + if (kind !== "runtimes") capabilityKinds[kind].contribute(entry, out, agent); + for (const name of entry.credentials || []) { + const label = entry.name || entry.skill_id; + if (Object.hasOwn(botVariables, name)) { + if (!bot) throw new AppError(400, `「${label}」需要飞书机器人身份,请先在飞书接入中给这个 Agent 绑定机器人。`); + continue; + } + const secret = store.get("secrets", agent.credentials?.[name] || ""); + if (!secret) throw new AppError(400, `请在 Agent 中为「${label}」所需的 ${name} 选择密钥。`); + out.session.env[name] = secret.value; } + if (kind === "skills") out.summary.skills.push({ id: entry.skill_id, version: entry.version || null }); + else if (kind === "mcps") out.summary.mcps.push(entry.label); + else if (kind !== "runtimes") out.summary.capabilities.push({ name: entry.name, kind: entry.kind, version: entry.version }); + } + if (bot) + for (const [name, field] of Object.entries(botVariables)) + out.session.env[name] = field === "brand" ? bot.brand || "feishu" : bot[field]; out.session.vault_ids = [...new Set(out.session.vault_ids)]; return out; } +// The secrets and MCP credentials an Agent must choose for its bindings now. +export function requiredCredentials(store, agent) { + const env = new Set(), mcps = new Set(); + for (const { kind, entry } of needers(store, agent)) { + for (const name of entry.credentials || []) if (!Object.hasOwn(botVariables, name)) env.add(name); + if (kind === "mcps" && entry.requires_credential) mcps.add(entry.id); + } + return { env, mcps }; +} + // Agents that bind a capability; a bound capability cannot be deleted. export function boundAgents(store, kind, id) { const definition = kind === "capabilities" ? capabilityKinds.prompt : capabilityKinds[kind]; diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index d05185268..8b3c3ec94 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -110,8 +110,9 @@ export function feishuAPI(store) { throw new AppError(409, "配置已更新,请重新加载。"); if (body.brand !== undefined && !["feishu", "lark"].includes(body.brand)) throw new AppError(400, "机器人地区无效。"); - const name = text(body.name || "Feishu", "机器人名称", 80, true); const app_id = text(body.app_id, "App ID", 100).trim(); + // The name comes from Feishu once the bot connects; until then it shows the App ID. + const name = app_id === old.app_id && old.name ? old.name : app_id || "Feishu"; if ( app_id && store @@ -481,6 +482,8 @@ export async function createFeishuTransport(settings, receive) { const bot = result.bot || result.data?.bot; if (result.code !== 0 || !bot?.open_id) throw new Error("Cannot resolve Feishu bot identity"); + // The name people see in Feishu. + this.name = bot.app_name; readContext = feishuContextReader(client, { openId: bot.open_id, name: bot.app_name || "Parsar", @@ -1527,6 +1530,10 @@ export function feishuWorker(store, core, product, makeChannel, configId) { try { await channel.connect(); active = config; + // The bot keeps the name it has in Feishu; renaming the app there renames it here. + const saved = store.get("feishu_bots", configId); + if (channel.name && saved && saved.name !== channel.name) + store.put("feishu_bots", { ...saved, name: channel.name }); } catch { await channel.disconnect(); channel = undefined; diff --git a/example/parsar/server/product.mjs b/example/parsar/server/product.mjs index bb74f2569..40d87cc2c 100644 --- a/example/parsar/server/product.mjs +++ b/example/parsar/server/product.mjs @@ -9,7 +9,7 @@ import { feishuAPI } from "./feishu.mjs"; import { trialAPI } from "./trials.mjs"; import { sessionAPI } from "./sessions.mjs"; import { coreResources } from "./core-resources.mjs"; -import { boundAgents, capabilityKinds } from "./capability-kinds.mjs"; +import { boundAgents, capabilityKinds, credentialNames, requiredCredentials } from "./capability-kinds.mjs"; import { AppError, text, uuid } from "./store.mjs"; import { harnessOptions, @@ -32,13 +32,17 @@ const kinds = new Set([ "runtimes", "agents", "sessions", + "secrets", ]); +// A secret's value is write-only: reads say only whether one is saved. +const publicSecret = ({ value, ...row }) => ({ ...row, has_value: !!value }); export function productAPI(store, core, fetchImpl = fetch, origin) { const resources = coreResources(store, core); const sessions = sessionAPI(store, core, resources); const publicRow = (kind, row) => { if (kind === "providers") return publicProvider(row); if (kind === "runtimes") return publicRuntime(row); + if (kind === "secrets") return publicSecret(row); if (kind === "sessions") { const { request, ...record } = row; return { ...record, turn: sessions.turns.summary(record.id) }; @@ -139,6 +143,16 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { return store.list("audit").sort((a, b) => b.created_at - a.created_at); if (path === "/app/skills/registry/preview" && method === "POST") return previewRegistrySkill(body); + if (path === "/app/skills/credentials" && method === "GET") return store.list("skill_credentials"); + // The credentials a Core Skill needs; the Skill itself stays in Core. + const skillCredentials = path.match(/^\/app\/skills\/(skill_[A-Za-z0-9][A-Za-z0-9_-]{0,127})\/credentials$/)?.[1]; + if (skillCredentials) { + if (method === "GET") return store.get("skill_credentials", skillCredentials) || { id: skillCredentials, credentials: [] }; + if (method !== "PUT") throw new AppError(405, "Method not allowed."); + const value = { id: skillCredentials, credentials: credentialNames(body.credentials) }; + store.put("skill_credentials", value); + return value; + } if (path.startsWith("/app/feishu/provision/")) return registration(method, path, body); if (path === "/app/feishu" || path.startsWith("/app/feishu/")) @@ -210,7 +224,8 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { store.list("feishu_bots").some((row) => row.agent_id === id) || store.list("models").some((row) => row.provider_id === id) || store.list("agents").some((row) => row.model_id === id || row.runtime_id === id) || - (kind === "mcps" && boundAgents(store, "mcps", id).length > 0); + (kind === "mcps" && boundAgents(store, "mcps", id).length > 0) || + (kind === "secrets" && store.list("agents").some((row) => Object.values(row.credentials || {}).includes(id))); if (used) throw new AppError(409, "资源仍被引用,请先调整绑定。"); if (kind === "agents" || kind === "runtimes") await resources.remove(kind, previous); store.transaction(() => { @@ -262,6 +277,13 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { throw new AppError(409, "此 Provider 已有相同的模型 ID。"); } if (kind === "mcps") Object.assign(value, capabilityKinds.mcps.validate(body)); + if (kind === "secrets") { + // Omitting the value keeps the saved one. + const secret = body.value ?? previous?.value; + if (typeof secret !== "string" || !secret || secret.includes("\0") || secret.length > 8192) + throw new AppError(400, "请填写密钥的值。"); + value.value = secret; + } if (kind === "runtimes") { Object.assign(value, runtimeProfile(body, previous)); } @@ -318,6 +340,25 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { if (version) value.capability_versions[capabilityId] = version; } value.mcp_ids = references("mcps", body.mcp_ids); + // Which secret supplies each required variable, and which Vault + // credential each MCP that needs one uses. Completeness is checked when + // the saved Core Agent is built. + const credentials = body.credentials ?? {}; + if (!credentials || typeof credentials !== "object" || Array.isArray(credentials)) + throw new AppError(400, "凭据选择无效。"); + credentialNames(Object.keys(credentials)); + value.credentials = Object.fromEntries( + Object.entries(credentials).map(([name, secretId]) => [name, requireReference("secrets", secretId)]), + ); + const mcpCredentials = body.mcp_credentials ?? {}; + if (!mcpCredentials || typeof mcpCredentials !== "object" || Array.isArray(mcpCredentials)) + throw new AppError(400, "MCP 凭据选择无效。"); + value.mcp_credentials = {}; + for (const [mcpId, choice] of Object.entries(mcpCredentials)) { + if (!value.mcp_ids.includes(mcpId) || !uuid.test(choice?.vault_id || "") || !uuid.test(choice?.credential_id || "")) + throw new AppError(400, "MCP 凭据选择无效。"); + value.mcp_credentials[mcpId] = { vault_id: choice.vault_id, credential_id: choice.credential_id }; + } if (Array.isArray(body.skill_ids) && body.skill_ids.length > maxSkills) throw new AppError(400, `最多选择 ${maxSkills} 个 Skill。`); if ( @@ -349,6 +390,12 @@ export function productAPI(store, core, fetchImpl = fetch, origin) { if (version) value.skill_versions[skillId] = version; } } + // Choices for credentials the bindings no longer need are dropped. + if (kind === "agents") { + const needed = requiredCredentials(store, value); + value.credentials = Object.fromEntries(Object.entries(value.credentials).filter(([name]) => needed.env.has(name))); + value.mcp_credentials = Object.fromEntries(Object.entries(value.mcp_credentials).filter(([id]) => needed.mcps.has(id))); + } // The saved Core Agent or Template keeps its identity across edits. for (const field of ["core_agent_id", "core_template_id", "core_digest"]) if (previous?.[field]) value[field] = previous[field]; diff --git a/example/parsar/server/runtime-profile.mjs b/example/parsar/server/runtime-profile.mjs index 00860288e..e36344b9c 100644 --- a/example/parsar/server/runtime-profile.mjs +++ b/example/parsar/server/runtime-profile.mjs @@ -1,4 +1,5 @@ -import { botFields, environmentVariableName } from "../shared/bot-bindings.mjs"; +import { environmentVariableName } from "../shared/bot-bindings.mjs"; +import { credentialNames } from "./capability-kinds.mjs"; import { posix } from "node:path"; import { AppError, text } from "./store.mjs"; @@ -50,26 +51,6 @@ export function runtimeProfile(body, previous) { throw new AppError(400, "请填写新环境变量的值;已有变量留空可保留。"); env[name] = selected; } - const bot_env = body.bot_env ?? {}; - if ( - !bot_env || - typeof bot_env !== "object" || - Array.isArray(bot_env) || - Object.keys(bot_env).length > 20 - ) - throw new AppError(400, "机器人变量映射格式无效。"); - for (const [name, source] of Object.entries(bot_env)) { - if ( - !environmentVariableName(name) || - Object.hasOwn(env, name) || - typeof source !== "string" || - !Object.hasOwn(botFields, source) - ) - throw new AppError( - 400, - "机器人变量名称必须有效、不能与固定变量重复,且只能选择机器人字段。", - ); - } const files = body.files ?? []; if (!Array.isArray(files) || files.length > 20) throw new AppError(400, "初始文件最多 20 个。"); @@ -95,7 +76,8 @@ export function runtimeProfile(body, previous) { packages, setup_script: text(body.setup_script || "", "初始化脚本", 100000), env, - bot_env, + // Variables the Agent supplies: the bound bot's identity or chosen secrets. + credentials: credentialNames(body.credentials), files: normalized, prepare: body.prepare === true, }; diff --git a/example/parsar/server/sessions.mjs b/example/parsar/server/sessions.mjs index dc43464ca..542056dc5 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { AppError, text, uuid } from "./store.mjs"; import { harnessOptions } from "../shared/harness-options.mjs"; -import { contributions } from "./capability-kinds.mjs"; +import { boundBot, contributions } from "./capability-kinds.mjs"; import { templateFiles } from "./core-resources.mjs"; import { turnsAPI } from "./turns.mjs"; @@ -12,10 +12,6 @@ const fingerprint = ({ request: { input, metadata, ...request }, core_digests }) createHash("sha256").update(JSON.stringify({ request, core_digests })).digest("hex"); const warmAge = 12 * 3600_000; -export function sessionBot(store, agentId) { - return store.list("feishu_bots").find((bot) => bot.agent_id === agentId); -} - // A Session references the Agent's saved Core Agent and its environment // template's Core Template, and adds only what differs per conversation: the // bot identity, the Agent's Skills, Plugins and knowledge, and attachments. @@ -29,13 +25,7 @@ export async function prepareSession(store, resources, id, body, origin = {}, wa const runtime = await resources.ensure("runtimes", storedRuntime.id); const model = store.get("models", agent.model_id); const bound = contributions(store, agent); - const botEnv = Object.entries(runtime.bot_env || {}); - const bot = sessionBot(store, agent.id); - if (botEnv.length && !bot?.app_secret) - throw new AppError( - 400, - "环境模板需要飞书机器人身份,请先在飞书接入中把机器人绑定到这个 Agent。", - ); + const bot = boundBot(store, agent); const provider = store.get("providers", model.provider_id); if (!provider?.base_url?.startsWith("https://") || !provider.api_key) throw new AppError( @@ -75,12 +65,13 @@ export async function prepareSession(store, resources, id, body, origin = {}, wa throw new AppError(400, "附件格式或大小无效。"); // Session files replace the Template's set, so they include the Template's own. const extraFiles = [...files, ...bound.session.files]; + // Variables overlay the Template's own: the bound bot's identity and the + // secrets the Agent chose for what its capabilities need. + const env = bound.session.env; const environment = { type: "openai_hosted", environment_template_id: runtime.core_template_id, - ...(botEnv.length - ? { env: Object.fromEntries(botEnv.map(([name, field]) => [name, field === "brand" ? bot.brand || "feishu" : bot[field]])) } - : {}), + ...(Object.keys(env).length ? { env } : {}), ...(bound.session.skills.length ? { skills: bound.session.skills } : {}), ...(bound.session.plugins.length ? { plugins: bound.session.plugins } : {}), ...(extraFiles.length ? { files: [...templateFiles(runtime), ...extraFiles] } : {}), @@ -102,17 +93,14 @@ export async function prepareSession(store, resources, id, body, origin = {}, wa agent_name: agent.name, agent_revision: agent.revision, runtime_revision: runtime.revision, - bot_name: botEnv.length ? bot.name : undefined, + bot_name: bot?.name, model: model.model, harness: agent.harness, environment: runtime.environment, environment_name: runtime.name, packages: runtime.packages || { npm: [], python: [] }, setup_configured: Boolean(runtime.setup_script), - env_names: [ - ...Object.keys(runtime.env || {}), - ...botEnv.map(([name]) => name), - ], + env_names: [...new Set([...Object.keys(runtime.env || {}), ...Object.keys(env)])], file_paths: (runtime.files || []).map((file) => file.path), ...bound.summary, }, diff --git a/example/parsar/shared/bot-bindings.d.mts b/example/parsar/shared/bot-bindings.d.mts new file mode 100644 index 000000000..21c3899da --- /dev/null +++ b/example/parsar/shared/bot-bindings.d.mts @@ -0,0 +1,2 @@ +export const botVariables: Record; +export function environmentVariableName(name: string): boolean; diff --git a/example/parsar/shared/bot-bindings.mjs b/example/parsar/shared/bot-bindings.mjs index 864937620..ecab4f6ac 100644 --- a/example/parsar/shared/bot-bindings.mjs +++ b/example/parsar/shared/bot-bindings.mjs @@ -1,8 +1,9 @@ -// Fields of the Agent's Feishu bot that an environment template may expose to tools. -export const botFields = { - app_id: "App ID", - app_secret: "App Secret", - brand: "地区", +// The bound Feishu bot's identity reaches every conversation of its Agent as +// these variables. Only the bot supplies them. +export const botVariables = { + FEISHU_APP_ID: "app_id", + FEISHU_APP_SECRET: "app_secret", + FEISHU_BRAND: "brand", }; export const environmentVariableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && diff --git a/example/parsar/src/AgentPage.tsx b/example/parsar/src/AgentPage.tsx index 701ec11e4..a246cf826 100644 --- a/example/parsar/src/AgentPage.tsx +++ b/example/parsar/src/AgentPage.tsx @@ -260,6 +260,7 @@ function AgentEditor({ {!creating && } setForm({ ...form, ...configuration })} diff --git a/example/parsar/src/App.tsx b/example/parsar/src/App.tsx index ebee2ac89..babc656a9 100644 --- a/example/parsar/src/App.tsx +++ b/example/parsar/src/App.tsx @@ -166,7 +166,7 @@ export function App() { href: "/capabilities", label: tr("资源库"), icon: BookOpen, - match: ["/capabilities", "/models", "/skills", "/mcps", "/credentials"], + match: ["/capabilities", "/models", "/credentials"], }, ]; const connect = [ @@ -204,10 +204,6 @@ export function App() { ) : path === "/models" ? ( - ) : path === "/skills" ? ( - - ) : path === "/mcps" ? ( - ) : path === "/credentials" ? ( ) : path === "/capabilities" ? ( diff --git a/example/parsar/src/Capabilities.tsx b/example/parsar/src/Capabilities.tsx index 5ac5d28af..bc6384151 100644 --- a/example/parsar/src/Capabilities.tsx +++ b/example/parsar/src/Capabilities.tsx @@ -1,18 +1,15 @@ import { tr } from "./lib/i18n"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Package, BookOpen, FileText } from "lucide-react"; import { product } from "./lib/product"; import { Models } from "./Models"; import { Credentials } from "./Credentials"; -import { Skills } from "./Skills"; -import { MCPs } from "./Resources"; +import { Library } from "./Library"; import { PageHeader, ErrorNotice, Field } from "./components/shared"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import { Textarea } from "./components/ui/textarea"; import { Select, SelectOption } from "./components/ui/select"; -import { Ledger, LedgerHeader, LedgerRow } from "./components/ui/ledger"; import { Dialog, DialogContent, @@ -20,13 +17,13 @@ import { DialogTitle, DialogFooter, } from "./components/ui/dialog"; -import { EmptyState } from "./components/ui/empty-state"; -import { randomUuid } from "@oac/agents-client"; export interface Capability { id: string; name: string; description: string; kind: "prompt" | "knowledge" | "plugin"; + // Variables a Plugin's programs need; each Agent supplies them from a secret. + credentials?: string[]; revision: number; deprecated: boolean; default_version: string; @@ -44,23 +41,16 @@ export const useCapabilities = () => queryFn: () => product("capabilities"), }); export function Capabilities({ - initialTab = "models", + initialTab = "capabilities", }: { initialTab?: string; }) { - const [tab, setTab] = useState(initialTab); - // Building blocks are grouped by what uses them: Agent engines, Agent - // capabilities, and MCP authentication. - const groups: [string, string][][] = [ - [["models", tr("模型")]], - [ - ["skills", "Skills"], - ["mcps", "MCP"], - ["plugin", "Plugins"], - ["knowledge", tr("知识")], - ["prompt", tr("指令片段")], - ], - [["credentials", tr("凭据")]], + // Each tab is a route, so reloading or going back keeps it. + const tab = initialTab; + const tabs: [string, string][] = [ + ["capabilities", tr("能力")], + ["models", tr("模型")], + ["credentials", tr("凭据")], ]; return ( <> @@ -69,107 +59,26 @@ export function Capabilities({
- {groups.map((group, index) => ( -
- {index > 0 && } - {group.map(([key, label]) => ( - - ))} -
- ))} -
- - {tab === "models" ? ( - - ) : tab === "credentials" ? ( - - ) : tab === "skills" ? ( - - ) : tab === "mcps" ? ( - - ) : ( - - )} - - ); -} -const labels = { - prompt: tr("指令片段"), - knowledge: tr("知识"), - plugin: "Plugins", -}; -function CapabilityList({ kind }: { kind: Capability["kind"] }) { - const query = useCapabilities(); - const [editing, setEditing] = useState(null); - const rows = query.data?.filter((row) => row.kind === kind); - const Icon = - kind === "plugin" ? Package : kind === "knowledge" ? BookOpen : FileText; - return ( - <> -
-

{labels[kind]}

- -
- - - - - {tr("名称")} - {tr("说明")} - {tr("默认版本")} - {tr("状态")} - - {rows?.map((row) => ( - - + {tabs.map(([key, label]) => ( - {row.description || "—"} - - v{row.default_version} - - {row.deprecated ? tr("已停用") : tr("可用")} - - ))} - {!rows?.length && } - - {editing && ( - setEditing(null)} /> - )} + ))} + + + {tab === "models" ? : tab === "credentials" ? : } ); } @@ -307,6 +216,10 @@ export function CapabilityEditor({ {selected.files.join("\n")} )} + setForm({ ...form, credentials })} + /> ) : ( @@ -355,3 +268,28 @@ export function CapabilityEditor({ ); } + +// The variables a capability needs, one per line. Each Agent that binds it +// chooses which secret supplies each. +export function CredentialNames({ + value, + change, +}: { + value: string[]; + change: (names: string[]) => void; +}) { + return ( + +