diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 3f340e04e..c8aa81f56 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -176,7 +176,7 @@ The runnable test-only example [`testdata/onboarding/main.go`](https://github.co ## Declare the Harness -Each Harness is one `var Declaration = harnessconfig.Declaration{...}` in `internal/harnessconfig//declaration.go`. The package name is the kind without underscores. Creating the file registers the Harness: `make generate-harness-catalog` generates the Core registry, the Runtime's `harnessDeclarations`, the client identifiers and display names and the [registration reference](./harness-catalog.md), and `make check-harness-catalog` rejects stale projections. `make openapi` derives the Harness enums from the same declarations, so do not add handwritten enums to DTO tags or route annotations. There is no dynamic plugin loader, and a declaration cannot state a machine's availability. +Each Harness is one `var Declaration = harnessconfig.Declaration{...}` in `internal/harnessconfig//declaration.go`. The package name is the kind without underscores. Creating the file registers the Harness: `make generate-harness-catalog` generates the Core registry, the Runtime's `harnessDeclarations`, the client identifiers and display names, the Parsar example's protocols and execution options and the [registration reference](./harness-catalog.md), and `make check-harness-catalog` rejects stale projections. `make openapi` derives the Harness enums from the same declarations, so do not add handwritten enums to DTO tags or route annotations. There is no dynamic plugin loader, and a declaration cannot state a machine's availability. The declaration holds: diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index e46201a6b..54f4db9cc 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 258dc7cba6a5b2959d9b7b525ab5a6a9ef4ed53939e44276773b71095467946e +source_hash: 4ab0e6623919424df5a56d302e3070bd2b59ad43416bce08547c4b136a33e4a4 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -178,7 +178,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res ## 声明 Harness {#declare-the-harness} -每个 Harness 是 `internal/harnessconfig//declaration.go` 中的一个 `var Declaration = harnessconfig.Declaration{...}`。包名是去掉下划线的 kind。创建该文件即注册 Harness:`make generate-harness-catalog` 生成 Core 注册表、Runtime 的 `harnessDeclarations`、客户端标识符和显示名称以及[注册参考](harness-catalog.md),`make check-harness-catalog` 拒绝过期的投影。`make openapi` 从同一批声明派生 Harness 枚举,因此不要在 DTO 标签或路由注解中手写枚举。没有动态插件加载器,声明也不能表示机器的可用性。 +每个 Harness 是 `internal/harnessconfig//declaration.go` 中的一个 `var Declaration = harnessconfig.Declaration{...}`。包名是去掉下划线的 kind。创建该文件即注册 Harness:`make generate-harness-catalog` 生成 Core 注册表、Runtime 的 `harnessDeclarations`、客户端标识符和显示名称、Parsar 示例使用的协议和执行选项,以及[注册参考](harness-catalog.md),`make check-harness-catalog` 拒绝过期的投影。`make openapi` 从同一批声明派生 Harness 枚举,因此不要在 DTO 标签或路由注解中手写枚举。没有动态插件加载器,声明也不能表示机器的可用性。 声明包含: diff --git a/example/parsar/README.md b/example/parsar/README.md index 9872c5b8a..e6a6aad5f 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. | -| **环境模板** | The prepared workspace: npm and Python packages, a Bash setup script, network access (allowed, blocked or limited to listed domains), environment variables, Feishu bot variables and initial files. | +| **资源库** | 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, network access (allowed, blocked or limited to listed domains), 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. 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 **用新配置开始新对话**. @@ -84,15 +84,15 @@ Quick Tunnel URLs last only for that tunnel process and may change after restart ## Web chat -**新对话** opens the home page: choose an Agent, describe the work and send. The sidebar lists recent conversations; **全部对话** opens the full list with search, Agent, source and date filters, plus **运行**, **用量** and **审计日志**. **运行** also sits directly under **新对话** with the number of running conversations. It lists every running turn from Web and Feishu with its stage, waiting time and the Agent's usual first-reply time, and the recently finished ones with their duration, automatic recoveries and plain-language failures; it also shows how many conversations are running, waiting for an answer or failed in the last five minutes. Running turns can be stopped and the latest failed turn of a conversation retried there. Date filters use the browser’s local dates. Usage totals cover all matching records; missing usage remains unknown. Optional model prices are USD per million input/output tokens, frozen when a conversation starts; costs are estimates, not provider bills. +**新对话** opens the home page: choose an Agent, describe the work and send. The sidebar lists recent conversations; **全部对话** opens the full list with search, Agent, source and date filters, plus **运行**, **用量** and **审计日志**. **运行** also sits directly under **新对话** with the number of running conversations. It lists every running turn from Web and Feishu with its stage and waiting time, and the recently finished ones with their duration, automatic recoveries and plain-language failures; it also shows how many conversations are running, waiting for an answer or failed in the last five minutes. Running turns can be stopped and the latest failed turn of a conversation retried there. Date filters use the browser’s local dates. Usage totals cover all matching records; missing usage remains unknown. Optional model prices are USD per million input/output tokens, frozen when a conversation starts; costs are estimates, not provider bills. -A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working reach the running task; answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 5 MiB while idle, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key. +A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working, from Web or Feishu, join the running task: **运行** shows it once, and in Feishu it keeps its progress card. Answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 50 MiB while idle through Core's Files API, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key. When a follow-up response is lost while Core resumes the environment, Web keeps the input in the running ledger and confirms receipt with the original idempotency key before monitoring its outcome. Explicit request rejections still return an error; input that never starts is reported after its reservation expires. To show thinking, set the Agent’s **高级 → 推理摘要** to `auto` and start a new conversation. When the Harness emits a native summary, **思考过程** appears directly in the conversation, separate from tool steps. While thinking is in progress it shows the latest text; after completion it defaults to a single row. Click to read the full summary, which is restored from history after reconnection. Existing conversations keep the configuration recorded at creation; enabling summaries does not add thinking to old history. -Parsar watches every running turn, from Web and Feishu alike. Before the first output it shows the stage (**正在准备工作环境**, **正在启动**) with the waiting time and the Agent's usual first-reply time from the last seven days. Recovery is automatic and happens only while nothing has run: an Environment that fails to initialize, or a claimed prepared conversation whose first turn has not started within 45 seconds, is replaced once with the identical frozen request; a turn without any output 45 seconds after it started is cancelled and its input resent once; a turn that fails before any output is resent once. Replaced turns are hidden from the conversation and never reported in Feishu. A final failure is explained in plain language with **重试**. **产物** lists the files recent conversations produced, linked to their conversation. +Parsar watches every running turn, from Web and Feishu alike. Before the first output it shows the stage (**正在准备工作环境**, **正在启动**) with the waiting time. Recovery is automatic and happens only while nothing has run: an Environment that fails to initialize, or a claimed prepared conversation whose first turn has not started within 45 seconds, is replaced once with the configuration the conversation was created with, sent inline, so later edits to the Agent or its template do not reach it; a turn without any output 45 seconds after it started is cancelled and its input resent once; a turn that fails before any output is resent once. Replaced turns are hidden from the conversation and never reported in Feishu. A final failure is explained in plain language with **重试**. **产物** lists the files recent conversations produced, linked to their conversation. Optional business questions use the declared `ask_user` function. They appear in the conversation and return through public function-result events with stable request IDs. This is information gathering, not execution approval. @@ -100,16 +100,20 @@ Optional business questions use the declared `ask_user` function. They appear in ## Agents and resources -**Agent** lists Agents as cards with their engine, model, environment template and bound Feishu bot. The Agent page groups **基本信息**, **执行与环境**, **指令**, **能力** and **行为**. **主指令** is the Agent’s own instruction text; optional **指令片段** are appended in selection order to produce one system prompt. Selection limits follow Core's [Environment limits](../../contracts/agents-api/environments.md#plugins-and-capability-directories): up to 50 Skills and 50 Plugins, and up to 50 knowledge items and environment-template files together; instruction fragments and MCP servers have no count limit. Bind only what the Agent needs, since every Skill and tool adds to what the model reads. In **执行与环境**, the Harness narrows every option to its declared values ([Harness capabilities](../../contracts/agents-api/harness-capabilities.md)); switching Harness resets values the new one does not accept and says which. Empty options keep the Harness default; web search defaults to **关闭**. Web conversations and Feishu step records show searches as **搜索网页**. Creating or editing a model, Skill, MCP, environment template or capability from the Agent page keeps unsaved fields, and a newly created resource is selected automatically. Unsaved Agent edits are restored when you return to the page in the same browser tab, and **保存** points to any missing required field. An existing Agent's page shows the last seven days: success rate, median first reply and automatic recoveries. +**Agent** lists Agents as cards with their engine, model, environment template and bound Feishu bot. The Agent page groups **基本信息**, **执行与环境**, **指令**, **能力** and **行为**. **主指令** is the Agent’s own instruction text; optional **指令片段** are appended in selection order to produce one system prompt. Selection limits follow Core's [Environment limits](../../contracts/agents-api/environments.md#plugins-and-capability-directories): up to 50 Skills and 50 Plugins, and up to 50 knowledge items and environment-template files together; instruction fragments and MCP servers have no count limit. Bind only what the Agent needs, since every Skill and tool adds to what the model reads. In **执行与环境**, the Harness narrows every option to its declared values ([Harness capabilities](../../contracts/agents-api/harness-capabilities.md)), which `make generate-harness-catalog` projects into [shared/harness-catalog.mjs](shared/harness-catalog.mjs); switching Harness resets values the new one does not accept and says which. Empty options keep the Harness default; web search defaults to **关闭**. Web conversations and Feishu step records show searches as **搜索网页**. Creating or editing a model, Skill, MCP, environment template or capability from the Agent page keeps unsaved fields, and a newly created resource is selected automatically. Unsaved Agent edits are restored when you return to the page in the same browser tab, and **保存** points to any missing required field. An existing Agent's page shows the last seven days: success rate, median first reply and automatic recoveries. **模型** 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. 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. **需要的密钥** 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. @@ -117,23 +121,23 @@ Optional business questions use the declared `ask_user` function. They appear in ## Feishu -**飞书接入** shows one card per bot. **添加机器人** offers **扫码创建**, which provisions an application through Feishu’s device flow, or **已有应用** with an App ID and App Secret. A new bot stays disabled until configured. Secrets stay in the restricted local database. The official SDK owns authentication, normalization and long connections; no inbound public callback is needed. +**飞书接入** shows one card per bot. **添加机器人** offers **扫码创建**, which provisions an application through Feishu’s device flow with app-identity scopes pre-filled on the confirmation page, where you also name the app: `im:message.group_msg` and the messaging, Base, Calendar, Docs, Sheets, Slides, Wiki, Tasks, meetings and contact scopes the organization grants without review ([the list](server/feishu-registration.mjs)); scopes that need review are applied for only when an Agent needs them, or **已有应用** with an App ID and App Secret. A bot's name is its app's name in Feishu: Parsar reads it when the bot is created or added, and again whenever it connects. A new bot stays disabled until configured. Secrets stay in the restricted local database. The official SDK owns authentication, normalization and long connections; no inbound public callback is needed. 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. -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. +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 “read all group messages” scope (`im:message.group_msg`). **扫码创建** requests it; for **已有应用**, enable it in the Feishu developer console. 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 50 MiB total; files reach the workspace through Core's Files API. 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. 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. 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. Every step stays in the card: to stay within Feishu's card size limit, the oldest steps first drop their tool output, then shrink to one line, and only a turn with hundreds of steps summarizes its oldest ones in one line. Feishu messages never send people to the Web. 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 -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. Each Feishu message reaches it after a first line naming its `message_id`, `chat_id`, chat type and sender `open_id`, so a Skill can key work to the message; Parsar's own views hide that line. When the Agent's reply names scopes its tools reported missing, the card says **需要开通权限** and adds, under the reply, a link to applying for those scopes in the Feishu developer console; scopes the Agent only ran into while exploring are not asked for. Once an administrator approves the new version, replying continues. 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 @@ -143,7 +147,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 | | --- | --- | @@ -176,6 +180,6 @@ It executes a real model and leaves resources for inspection. Live Feishu accept ### Chat media -Web conversations preview PNG, JPEG, GIF and WebP Session Artifacts beside the reply and provide file downloads. Save outputs under `./outputs`; Markdown image/file links to their `/workspace/outputs/` paths resolve to the persisted artifacts. Other formats, including SVG, remain downloads; external image URLs are not fetched. Feishu sends each artifact as an image or file message, with a 5 MiB limit per nonempty attachment. Upload keys and message identities persist across retries; failed deliveries remain in **消息记录**. Audio and video are downloadable files, without inline playback. +Web conversations preview PNG, JPEG, GIF and WebP Session Artifacts beside the reply and provide file downloads. Save outputs under `./outputs`; Markdown image/file links to their `/workspace/outputs/` paths resolve to the persisted artifacts. Other formats, including SVG, remain downloads; external image URLs are not fetched. Feishu sends the artifacts the Agent's reply names as image or file messages, replying to the message the turn answers, with a 5 MiB limit per nonempty attachment; other artifacts stay in the Web conversation. Upload keys and message identities persist across retries; failed deliveries remain in **消息记录**. Audio and video are downloadable files, without inline playback. Bot registration returns `verification_url` and a relative `qr_url`. Show the authorization link and `![飞书授权二维码]()` with the returned path substituted. The PNG is generated inside Parsar, and `GET /app/feishu/provision//qrcode` rejects expired or completed registrations. Web and Feishu use the same image; neither sends authorization URLs to a third-party QR service. diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index c0505f0e3..32891841d 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: 60d0bf3a46840a363113be51df079bc4e1fc19c9b4ca88d2af145a6bc4581a9a --- # Parsar 工作空间 @@ -26,11 +26,11 @@ pnpm --filter @oac/parsar-example dev | 对象 | 含义 | | --- | --- | -| **资源库** | 组装用的零件,按使用方分组:模型供 Agent 的执行引擎使用;Skills、MCP 服务、Plugins、知识和指令片段供 Agent 使用;凭据供 MCP 使用。资源是共享的:修改一项会影响所有使用它的对象,正在使用的条目不能移除。 | -| **环境模板** | 准备好的工作区:npm 与 Python 软件包、Bash 初始化脚本、联网权限(允许、禁止或仅允许指定域名)、环境变量、飞书机器人变量和初始文件。 | +| **资源库** | 组装用的零件,按使用方分组:模型供 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 或其环境模板变更后,已有对话会给出提示,并提供 **用新配置开始新对话**。 @@ -89,15 +89,15 @@ Quick Tunnel 地址只在该隧道进程运行期间有效,重启可能换地 ## 网页聊天 {#web-chat} -**新对话** 打开首页:选择 Agent、描述工作并发送。侧栏列出最近对话;**全部对话** 打开完整列表,支持搜索以及按 Agent、来源和日期筛选,并提供 **运行**、**用量** 和 **审计日志**。**运行** 也直接放在 **新对话** 下方,并显示正在进行的对话数。它列出网页和飞书中每一轮正在执行的对话,显示所处阶段、已等待时间和这个 Agent 通常的首次回复时间;也列出最近完成的轮次,显示用时、自动恢复次数和通俗的失败原因,并统计正在进行、等你回复和近 5 分钟失败的数量。可以在这里停止正在执行的轮次,或重试对话中最近失败的一轮。日期筛选按浏览器本地日期计算;用量汇总覆盖所有匹配记录,缺失用量保持未知。模型可配置每百万输入/输出 Token 的美元价格,在对话开始时冻结;费用为估算,不是服务商账单。 +**新对话** 打开首页:选择 Agent、描述工作并发送。侧栏列出最近对话;**全部对话** 打开完整列表,支持搜索以及按 Agent、来源和日期筛选,并提供 **运行**、**用量** 和 **审计日志**。**运行** 也直接放在 **新对话** 下方,并显示正在进行的对话数。它列出网页和飞书中每一轮正在执行的对话,显示所处阶段和已等待时间;也列出最近完成的轮次,显示用时、自动恢复次数和通俗的失败原因,并统计正在进行、等你回复和近 5 分钟失败的数量。可以在这里停止正在执行的轮次,或重试对话中最近失败的一轮。日期筛选按浏览器本地日期计算;用量汇总覆盖所有匹配记录,缺失用量保持未知。模型可配置每百万输入/输出 Token 的美元价格,在对话开始时冻结;费用为估算,不是服务商账单。 -对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息会进入正在运行的任务;业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时上传最大 5 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。 +对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息(网页和飞书都一样)会并入正在运行的任务:**运行** 里只显示这一轮,飞书里沿用这一轮的进度卡片。业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时通过 Core 的 Files API 上传最大 50 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。 恢复环境时,如果后续消息的请求响应超时或丢失,网页会保留运行记录,使用原幂等键确认接收后继续跟踪执行结果,不会重复执行。明确被拒绝的请求仍返回错误;始终未开始执行的输入会在预留期结束后报告失败。 如需展示 thinking,将 Agent 的 **高级 → 推理摘要** 设为 `auto`,再新建对话。Harness 返回原生摘要时,**思考过程** 会直接出现在对话中,与工具步骤分开。生成时显示最新片段,结束后默认收成一行;点击可阅读完整摘要,断线后从历史恢复。已有对话保留创建时的配置;开启摘要不会为旧历史补出思考内容。 -Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出现第一条输出之前,会显示所处阶段(**正在准备工作环境**、**正在启动**)、已等待的时间,以及这个 Agent 近 7 天通常的首次回复时间。自动恢复只在还没有执行任何内容时进行:环境初始化失败,或接管的提前准备会话 45 秒内仍未开始第一轮时,用同一份冻结的请求重建一次;一轮对话开始 45 秒后仍没有任何输出,会取消并重新发送一次输入;在任何输出之前就失败的一轮,会重新发送一次。被替换的轮次不会出现在对话中,也不会发到飞书。最终失败会用通俗的话说明原因,并提供 **重试**。**产物** 汇总最近对话生成的文件,并链接到对应的对话。 +Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出现第一条输出之前,会显示所处阶段(**正在准备工作环境**、**正在启动**)和已等待的时间。自动恢复只在还没有执行任何内容时进行:环境初始化失败,或接管的提前准备会话 45 秒内仍未开始第一轮时,用对话创建时的配置(内联发送)重建一次,之后对 Agent 或环境模板的修改不会影响它;一轮对话开始 45 秒后仍没有任何输出,会取消并重新发送一次输入;在任何输出之前就失败的一轮,会重新发送一次。被替换的轮次不会出现在对话中,也不会发到飞书。最终失败会用通俗的话说明原因,并提供 **重试**。**产物** 汇总最近对话生成的文件,并链接到对应的对话。 可选的业务提问使用声明的 `ask_user` 函数。问题出现在对话中,并通过带稳定请求 ID 的公开 function-result 事件返回。这是收集信息,不是执行审批。 @@ -109,12 +109,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 已绑定机器人。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` 保留已有值,字符串替换,删除名称即删除变量。**需要的密钥** 列出由 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 在等待期间会占用一个工作区。 @@ -122,23 +126,23 @@ Parsar 会跟踪每一轮正在执行的对话,网页和飞书都一样。出 ## 飞书 {#feishu} -**飞书接入** 为每个机器人展示一张卡片。**添加机器人** 提供 **扫码创建**(通过飞书设备流创建应用)和 **已有应用**(填写 App ID 与 App Secret)。新机器人在配置完成前保持停用。密钥保存在受限的本地数据库中。官方 SDK 负责鉴权、消息规范化和长连接,不需要公网回调。 +**飞书接入** 为每个机器人展示一张卡片。**添加机器人** 提供 **扫码创建**(通过飞书设备流创建应用,扫码后的确认页会预先填好应用身份权限,应用名称在确认页里自己填写:`im:message.group_msg`,以及企业免审的消息、多维表格、日历、文档、电子表格、幻灯片、知识库、任务、会议和通讯录权限,[完整清单](server/feishu-registration.mjs);需要审核的权限只在 Agent 确实用到时再申请)和 **已有应用**(填写 App ID 与 App Secret)。机器人的名称就是它在飞书里的应用名:创建或添加机器人时,Parsar 会读取一次,之后每次连接时再同步。新机器人在配置完成前保持停用。密钥保存在受限的本地数据库中。官方 SDK 负责鉴权、消息规范化和长连接,不需要公网回调。 1. 在卡片上选择 **Agent**,打开 **私聊** 和/或 **群聊**,然后 **保存并启用**。启用需要应用凭证、已配置环境模板的 Agent,以及两者至少开启一项。打开 **群聊** 后,机器人会回复它所在的任何群里的 @ 消息,把机器人拉进群即可使用。谁能添加机器人由开发者后台中应用的可用范围控制。 2. 在开发者后台启用机器人、长连接事件推送和 `im.message.receive_v1`,发布应用并把它加入目标群。授予接收 @ 消息和以机器人身份发送消息的权限;私聊、消息资源和表情回复需要各自的权限。缺少表情权限不会丢弃消息。 -3. 私聊为每个发送者保持一个对话,直到发送 `/new` 或 `/reset`。群聊中 @ 机器人会开启一个话题,每个话题是一个对话,所有在其中回复的人共享。只有应用开通了敏感权限“获取群组中所有消息”(`im:message.group_msg`),话题中不带 @ 的后续消息才会推送给机器人;否则需要在话题中再次 @ 机器人。话题中只 @ 了其他人的回复是发给他们的,会被忽略。其他机器人和 机器人只服务自己所在的企业,外部联系人发来的消息会被忽略。`@所有人` 会被忽略;关闭 **群聊** 时忽略群消息,关闭 **私聊** 时忽略私聊。更换绑定的 Agent 只影响新话题和新的私聊对话。 -4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 5 MiB。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 -5. `/cancel`、`/answer <回答>`、`/new` 和 `/help` 在同一话题中使用。只有一个待回答的业务问题时,直接回复即可作答。取消优先于排队中的追问,并会先确认不确定的创建再取消。 +3. 私聊为每个发送者保持一个对话,直到发送 `/clear`。群聊中 @ 机器人会开启一个话题,每个话题是一个对话,所有在其中回复的人共享。只有应用开通了“获取群组中所有消息”权限(`im:message.group_msg`),话题中不带 @ 的后续消息才会推送给机器人。**扫码创建** 会申请这个权限;**已有应用** 需要在飞书开发者后台开通。话题中只 @ 了其他人的回复是发给他们的,会被忽略。其他机器人和 机器人只服务自己所在的企业,外部联系人发来的消息会被忽略。`@所有人` 会被忽略;关闭 **群聊** 时忽略群消息,关闭 **私聊** 时忽略私聊。更换绑定的 Agent 只影响新话题和新的私聊对话。 +4. 文本、富文本、图片、文件、音视频、引用消息和合并转发会在投递前规范化。附件上限为 8 个、合计 50 MiB,文件通过 Core 的 Files API 写入工作区。后续消息中的文件会等到对话空闲后写入。引用消息最多读取四层;不读取更早的群消息。 +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} -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 时,正文前会附一行来源信息:`message_id`、`chat_id`、会话类型和发送者 `open_id`,Skill 可以据此对消息做幂等处理;Parsar 自己的页面不显示这一行。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 @@ -148,7 +152,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}`。 | 产品路由组 | 调用方与凭据 | | --- | --- | @@ -181,6 +185,6 @@ pnpm --filter @oac/parsar-example test:live ### 聊天中的图片与文件 -网页会话在回复旁预览 PNG、JPEG、GIF 和 WebP 产物,并提供文件下载。将输出保存到 `./outputs`;Markdown 中指向 `/workspace/outputs/` 的图片和文件链接会对应到已保存的产物。SVG 等其他格式只提供下载,不加载外部图片地址。飞书将每个产物发送为图片或文件消息,单个附件不能为空且不能超过 5 MiB。上传标识和消息标识会保留以供重试,失败记录显示在**消息记录**中。音视频作为文件下载,暂不在聊天中播放。 +网页会话在回复旁预览 PNG、JPEG、GIF 和 WebP 产物,并提供文件下载。将输出保存到 `./outputs`;Markdown 中指向 `/workspace/outputs/` 的图片和文件链接会对应到已保存的产物。SVG 等其他格式只提供下载,不加载外部图片地址。飞书只发送 Agent 回复里提到的产物,以图片或文件消息回复这一轮所回答的那条消息,单个附件不能为空且不能超过 5 MiB;其他产物留在网页对话中。上传标识和消息标识会保留以供重试,失败记录显示在**消息记录**中。音视频作为文件下载,暂不在聊天中播放。 机器人注册返回 `verification_url` 和相对地址 `qr_url`。同时展示授权链接和 `![飞书授权二维码]()`,将占位符替换为返回的路径。PNG 由 Parsar 本地生成;注册过期或完成后,`GET /app/feishu/provision//qrcode` 返回失效提示。网页和飞书使用同一张二维码,不会把授权地址发送给第三方二维码服务。 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.mjs b/example/parsar/server.mjs index bb61ecb04..674db53b9 100644 --- a/example/parsar/server.mjs +++ b/example/parsar/server.mjs @@ -1,4 +1,4 @@ -import { readMediaBytes } from "./shared/media.mjs"; +import { maxUploadBytes, readMediaBytes } from "./shared/media.mjs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createServer } from "node:http"; @@ -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)); @@ -23,6 +24,7 @@ const routes = [ ], [/^\/v1\/agents\/environments\/[a-f0-9-]{36}$/, ["GET"]], [/^\/v1\/agents\/environments\/[a-f0-9-]{36}\/files$/, ["GET", "POST"]], + [/^\/v1\/files$/, ["POST"]], [/^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/artifacts$/, ["GET"]], [ /^\/v1\/agents\/sessions\/[a-f0-9-]{36}\/artifacts\/[a-f0-9-]{36}\/content$/, @@ -31,8 +33,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( [ @@ -98,16 +98,18 @@ function fail(res, status, message) { export function coreClient(config, fetchImpl = fetch) { return async (path, method, body, key) => { + // A FormData body is a multipart upload, which sets its own Content-Type. + const form = body instanceof FormData; const response = await fetchImpl(`${config.target}${path}`, { method, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(body === undefined ? {} : { body: form ? body : JSON.stringify(body) }), redirect: "manual", - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(form ? 300_000 : 30_000), headers: { ...(key ? { "Idempotency-Key": key } : {}), Authorization: `Bearer ${config.key}`, "OpenAI-Beta": "agents=v1", - "Content-Type": "application/json", + ...(form ? {} : { "Content-Type": "application/json" }), }, }); if (response.status >= 300 && response.status < 400) @@ -250,6 +252,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(); @@ -267,10 +273,12 @@ export function createHandler( size += chunk.length; if ( size > - (url.pathname.startsWith("/v1/skills") || - url.pathname.endsWith("/files") - ? 8 * 1024 * 1024 - : 1024 * 1024) + (url.pathname === "/v1/files" + ? maxUploadBytes + 64 * 1024 + : url.pathname.startsWith("/v1/skills") || + url.pathname.endsWith("/files") + ? 8 * 1024 * 1024 + : 1024 * 1024) ) return fail(res, 413, "Request too large."); chunks.push(chunk); @@ -281,7 +289,7 @@ export function createHandler( "Content-Type": req.headers["content-type"] || "application/json", ...(streaming ? { Accept: "text/event-stream" } : {}), }; - if (url.pathname.startsWith("/v1/skills")) + if (url.pathname.startsWith("/v1/skills") || url.pathname === "/v1/files") delete headers["OpenAI-Beta"]; if (req.headers["idempotency-key"]) headers["Idempotency-Key"] = req.headers["idempotency-key"]; @@ -420,6 +428,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..92dc25ef5 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, credentialNames } 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 {}; @@ -107,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 new file mode 100644 index 000000000..7e4186a36 --- /dev/null +++ b/example/parsar/server/capability-kinds.mjs @@ -0,0 +1,172 @@ +import { AppError, text } from "./store.mjs"; +import { botVariables, environmentVariableName } from "../shared/bot-bindings.mjs"; +import { harnessOptions } from "../shared/harness-options.mjs"; + +// Every kind of capability an Agent can bind, in one place. A kind resolves an +// 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); + 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. + // 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 } : {}) }), + }, + 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; + }, + // 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, + transport: { type: "http", server_url: mcp.url }, + connection_origin: "environment", + // Required only where the Harness declares required HTTP MCP servers. + required: harnessOptions[agent.harness]?.mcp_http_required === true, + ...(credential ? { credential_id: credential.credential_id } : {}), + }); + if (credential) out.session.vault_ids.push(credential.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 地址。"); + const label = text(body.label, "MCP 标识", 64, true); + if (!/^[a-zA-Z0-9_-]+$/.test(label)) + throw new AppError(400, "MCP 标识仅支持字母、数字、下划线和连字符。"); + return { url: url.href, label, requires_credential: body.requires_credential === true }; + }, + }, + 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 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: [], env: {} }, + summary: { skills: [], mcps: [], capabilities: [] }, + }; + 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]; + 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..a9d2d8829 --- /dev/null +++ b/example/parsar/server/core-resources.mjs @@ -0,0 +1,138 @@ +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), + // Null is enabled; an update must send it, since an omitted field keeps its value. + network: runtime.network || null, + }; +} + +// The saved Agent and Template as an inline Session configuration, frozen when +// a conversation is created. Replacing its Session later uses this copy, so +// edits to the saved resources reach only new conversations. +export function frozenConfiguration(store, agent, runtime) { + const { name: _agentName, metadata: _metadata, ...inline } = withoutNulls(coreAgent(store, agent)); + const { name: _templateName, ...template } = coreTemplate(runtime); + return { agent: inline, template }; +} + +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/feishu-registration.mjs b/example/parsar/server/feishu-registration.mjs index 2cda4fe70..d2c7a2cec 100644 --- a/example/parsar/server/feishu-registration.mjs +++ b/example/parsar/server/feishu-registration.mjs @@ -1,7 +1,83 @@ +import { gzipSync } from "node:zlib"; import QRCode from "qrcode"; import { qrImagePattern } from "../shared/media.mjs"; import { AppError, uuid, text } from "./store.mjs"; +// App-identity scopes the new app requests on top of the platform's default +// template, so an Agent can work with Feishu data without its users applying +// for each scope later. All of them are ones the organization grants without +// review; scopes that need review are left for when an Agent truly needs them. +// Without im:message.group_msg the bot never receives topic replies that do +// not mention it. +export const botScopes = [ + // Messages and groups + "im:message", "im:message:readonly", "im:message:send_as_bot", "im:message:send_multi_users", "im:message:update", + "im:message.group_msg", "im:message.group_msg.include_bot:read", "im:message.group_at_msg:readonly", + "im:message.group_at_msg.include_bot:readonly", "im:message.p2p_msg:readonly", "im:message.pins:read", "im:message.pins:write_only", + "im:message.reactions:read", "im:message.reactions:write_only", "im:resource", + "im:chat", "im:chat:read", "im:chat:readonly", "im:chat:create", "im:chat:update", "im:chat.members:read", + "im:chat.members:write_only", "im:chat.members:bot_access", "im:chat.announcement:read", "im:chat.announcement:write_only", + "im:chat.moderation:read", "im:chat.tabs:read", "im:chat.access_event.bot_p2p_chat:read", "search:message", + // The bot itself, cards + "application:application:self_manage", "application:bot.basic_info:read", "application:bot.menu:readonly", + "application:bot.menu:write", "application:app_slash_command:read", "application:app_slash_command:write", + "cardkit:card:read", "cardkit:card:write", "cardkit:template:read", + // People + "contact:contact.base:readonly", "contact:user.base:readonly", "contact:user.id:readonly", "contact:user.employee_id:readonly", + "contact:user.email:readonly", "contact:department.base:readonly", "tenant:tenant:readonly", + // Base + "bitable:app", "bitable:app:readonly", "base:app:read", "base:app:create", "base:app:update", "base:app:copy", + "base:table:read", "base:table:create", "base:table:update", "base:field:read", "base:field:create", "base:field:update", + "base:record:read", "base:record:retrieve", "base:record:create", "base:record:update", "base:record:delete", + "base:view:read", "base:view:write_only", "base:dashboard:read", "base:form:read", "base:workspace:list", "base:workspace:read", + // Calendar and meetings + "calendar:calendar", "calendar:calendar:readonly", + "vc:meeting.search:read", "vc:note:read", "vc:reserve:readonly", "vc:room:readonly", + "minutes:minutes.artifacts:read", "minutes:minutes.search:read", + // Documents + "docx:document", "docx:document:create", "docx:document:readonly", "docx:document:write_only", "docx:document.block:convert", + "docs:document.content:read", "docs:document.media:download", "docs:document.media:upload", "docs:document:copy", + "docs:document:export", "docs:document:import", "docs:document.comment:read", "docs:document.comment:create", + "docs:document.comment:update", "docs:document.comment:delete", "docs:document.comment:write_only", + "docs:permission.member:create", "docs:permission.member:retrieve", "docs:permission.setting:read", + "drive:drive.metadata:readonly", "drive:file:download", "drive:file:upload", "drive:drive:version:readonly", "space:folder:create", + "sheets:spreadsheet", "sheets:spreadsheet:read", "sheets:spreadsheet:readonly", "sheets:spreadsheet:create", + "sheets:spreadsheet:write_only", "sheets:spreadsheet.meta:read", + "slides:presentation:create", "slides:presentation:read", "slides:presentation:update", "slides:presentation:write_only", + "board:whiteboard:node:read", "board:whiteboard:node:create", "board:whiteboard:node:update", + "mindnote:node:read", "mindnote:node:create", + "wiki:wiki", "wiki:wiki:readonly", "wiki:space:read", "wiki:space:retrieve", "wiki:node:read", "wiki:node:retrieve", + "wiki:node:create", "wiki:node:update", "wiki:node:copy", + // Tasks + "task:task", "task:task:read", "task:task:readonly", "task:tasklist:read", "task:comment:read", "task:comment:write", + "task:attachment:read", "task:attachment:write", "task:section:read", "task:custom_field:read", + // Translation, OCR and speech + "translation:text", "optical_char_recognition:image", "speech_to_text:speech", +]; +// The name the app has in Feishu, read with its own credentials; undefined +// when Feishu cannot say. +export async function feishuAppName(fetchImpl, { app_id, app_secret, brand }) { + const host = brand === "lark" ? "open.larksuite.com" : "open.feishu.cn"; + const call = async (path, init) => + (await fetchImpl(`https://${host}${path}`, { ...init, redirect: "error", signal: AbortSignal.timeout(10000) })).json(); + try { + const { tenant_access_token: token } = await call("/open-apis/auth/v3/tenant_access_token/internal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_id, app_secret }), + }); + if (!token) return; + const info = await call("/open-apis/bot/v3/info", { headers: { Authorization: `Bearer ${token}` } }); + const name = (info.bot || info.data?.bot)?.app_name; + return typeof name === "string" && name.trim() ? name.trim().slice(0, 80) : undefined; + } catch { + return undefined; + } +} + +// The official SDKs' QR `addons` encoding. +const addons = gzipSync(JSON.stringify({ scopes: { tenant: botScopes } })).toString("base64url"); + // The SDK registerApp helper runs an in-memory polling loop and cannot resume a // persisted device code after restart. This adapter implements the same device // flow with durable polling state; codes and credentials never leave the server. @@ -55,7 +131,7 @@ export function feishuRegistrationAPI(store, fetchImpl = fetch) { if (!row) throw new AppError(404, "注册会话不存在。"); if (row.status !== "pending" || row.expires_at <= Date.now()) throw new AppError(410, "授权二维码已失效,请重新发起注册。"); - return QRCode.toBuffer(row.verification_url, { width: 320, margin: 4 }); + return QRCode.toBuffer(row.verification_url, { width: 640, margin: 4 }); } const id = path.match(/^\/app\/feishu\/provision\/([a-f0-9-]{36})$/)?.[1]; if (!id || !uuid.test(id)) throw new AppError(404, "Not found."); @@ -72,7 +148,7 @@ export function feishuRegistrationAPI(store, fetchImpl = fetch) { const bot = store.get("feishu_bots", id); if (bot?.app_id) throw new AppError(409, "机器人已有应用,请新建机器人进行扫码注册。"); - const name = text(body.name || "Feishu", "机器人名称", 80, true); + const name = body.name ? text(body.name, "机器人名称", 80, true) : ""; await call("init"); const value = await call("begin", { archetype: "PersonalAgent", @@ -97,6 +173,9 @@ export function feishuRegistrationAPI(store, fetchImpl = fetch) { verification.password ) throw new AppError(502, "飞书返回的授权地址无效。"); + // The confirmation page after scanning pre-fills the app's scopes, and its name when one was given. + if (name) verification.searchParams.set("name", name); + verification.searchParams.set("addons", addons); row = { id, name, @@ -156,11 +235,13 @@ export function feishuRegistrationAPI(store, fetchImpl = fetch) { .some((bot) => bot.app_id === value.client_id) ) throw new AppError(409, "应用已绑定,请检查机器人配置。"); + const brand = row.brand === "lark" ? "lark" : "feishu"; + const appName = await feishuAppName(fetchImpl, { app_id: value.client_id, app_secret: value.client_secret, brand }); store.transaction(() => { store.put("feishu_bots", { ...bot, id, - name: bot?.name || row.name, + name: appName || row.name || value.client_id, app_id: value.client_id, app_secret: value.client_secret, brand: row.brand === "lark" ? "lark" : "feishu", diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index fd709ee22..46d4baefd 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -1,4 +1,4 @@ -import { artifactContentPath, imageMime, maxMediaBytes, qrImages, qrImagePattern } from "../shared/media.mjs"; +import { artifactContentPath, imageMime, maxMediaBytes, maxUploadBytes, qrImages, qrImagePattern } from "../shared/media.mjs"; import { createHash, randomUUID } from "node:crypto"; const stableId = (value) => { const hex = createHash("sha256").update(value).digest("hex"); @@ -6,7 +6,9 @@ const stableId = (value) => { }; import { feishuContextReader } from "./feishu-context.mjs"; import { explainFailure, turnsAPI } from "./turns.mjs"; -import { typicalWait, waitingHeadline } from "../shared/turn-status.mjs"; +import { waitingHeadline } from "../shared/turn-status.mjs"; +import { feishuSource } from "../shared/feishu-source.mjs"; +import { feishuAppName } from "./feishu-registration.mjs"; import { AppError, text, uuid, transientError } from "./store.mjs"; const stamp = () => Date.now(); @@ -15,7 +17,7 @@ export const publicFeishu = ({ app_secret, ...record }) => ({ ...record, has_app_secret: !!app_secret, }); -export function feishuAPI(store) { +export function feishuAPI(store, fetchImpl = fetch) { return async (method, path, body) => { const retryMatch = path.match( /^\/app\/feishu\/deliveries\/(inbound|outbound)\/([a-f0-9-]{36})$/, @@ -89,20 +91,15 @@ export function feishuAPI(store) { groups: false, }; if (method === "GET") return publicFeishu(old); + // Deleting a bot stops it and drops what it had not delivered yet; + // conversations and delivered messages stay. if (method === "DELETE") { - if (old.enabled) throw new AppError(409, "请先停用机器人。"); - if ( - [ - ...store.list("feishu_inbound"), - ...store.list("feishu_outbound"), - ].some( - (row) => - row.app_id === old.app_id && - !["delivered", "rejected"].includes(row.state), - ) - ) - throw new AppError(409, "机器人仍有未完成投递,请先处理。"); - store.remove("feishu_bots", id); + store.transaction(() => { + for (const kind of ["feishu_inbound", "feishu_outbound"]) + for (const row of store.list(kind)) + if (row.app_id === old.app_id && !["delivered", "rejected"].includes(row.state)) store.remove(kind, row.id); + store.remove("feishu_bots", id); + }); return {}; } if (method !== "PUT") throw new AppError(405, "Method not allowed."); @@ -110,8 +107,11 @@ 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(); + // A new app takes the name it has in Feishu; connecting keeps it current. + const name = app_id === old.app_id && old.name + ? old.name + : (app_id && body.app_secret && (await feishuAppName(fetchImpl, { app_id, app_secret: body.app_secret.trim(), brand: body.brand ?? old.brand }))) || app_id || "Feishu"; if ( app_id && store @@ -169,12 +169,20 @@ export function feishuAPI(store) { }; } +// Feishu's rich-text box can bold or code-format a command; the command is +// what remains without that formatting. +export function asCommand(text) { + const plain = text.replace(/^[*_~`]+|[*_~`]+$/g, "").trim(); + return /^\/(clear|stop|help)$/.test(plain) || /^\/answer\s/.test(plain) ? plain : text; +} + const cardTones = { in_progress: ["处理中", "blue"], requires_action: ["需要你补充信息", "orange"], completed: ["已完成", "green"], cancelled: ["已停止", "grey"], failed: ["执行失败", "red"], + missing_scopes: ["需要开通权限", "orange"], }; const grey = (text) => `${text}`; @@ -192,16 +200,19 @@ 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", + 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: grey(title) }, + title: { tag: "markdown", content: title }, width: "fill", - padding: "8px 0px 8px 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, @@ -210,20 +221,25 @@ 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)); + +// The colored header carries the status and, while running, what is +// 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: title } }, + config: { update_multi: true, summary: { content: heading } }, + header: { title: { tag: "plain_text", content: heading }, template }, body: { - padding: "16px 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)] : []), - ], + padding: "12px 16px 12px 16px", + vertical_spacing: "6px", + elements: [...(body ? [{ tag: "markdown", content: body }] : []), ...elements], }, }); } @@ -242,7 +258,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) { @@ -258,7 +274,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 +283,114 @@ function step(item) { return ["执行步骤", item.name || item.type || ""]; } -function stepPanel(item) { +// 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(); + 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" + ? "失败" + : item.status === "in_progress" + ? grey("进行中") + : item.status === "incomplete" + ? grey("未完成") + : typeof item.duration_ms === "number" + ? 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 state = { completed: "完成", failed: "失败", incomplete: "未完成", in_progress: "进行中" }[item.status] || "状态未知"; - const duration = typeof item.duration_ms === "number" ? ` · ${(item.duration_ms / 1000).toFixed(1)}s` : ""; + const title = oneLine(label, detail, toolState(item)); const fields = [ - ["命令", item.command], ["工作目录", item.cwd], ["参数", item.arguments ?? item.action], + // 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 !== ""); + 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) } }], }); } +// Consecutive tool calls fold into one row; each call folds again inside it. +// With less room, calls keep only their titles, then the group is one line. +function toolsRow(items, level = 0) { + 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 + ? oneLine(label, detail, state) + : [grey(items.every((item) => step(item)[0] === "运行") ? `运行 ${items.length} 条命令` : `调用 ${items.length} 次工具`), state].filter(Boolean).join(" "); + if (level >= 2 || (level === 1 && items.length === 1)) return { tag: "markdown", content: title }; + const calls = level === 1 + ? items.map((item) => ({ tag: "markdown", content: oneLine(...step(item), toolState(item)) })) + : items.map(toolRow); + return panel({ title, elements: calls }); +} + +// One step at decreasing detail: everything; tool calls without their output; +// thinking and tool groups as one line; commentary shortened. +function timelineRow(entry, level = 0) { + if (entry.type === "say") return { tag: "markdown", content: excerpt(entry.text, level < 3 ? 4000 : 400) }; + if (entry.type === "think") { + const title = oneLine("思考", entry.text.split("\n").find((line) => line.trim()) || ""); + return level < 2 ? panel({ title, content: excerpt(entry.text, 1500) }) : { tag: "markdown", content: title }; + } + return toolsRow(entry.items, level); +} + +// Every step stays in the card. When it would pass Feishu's size limit, the +// oldest steps give up detail first. +function fitRows(entries, budget) { + const rows = entries.map((entry) => timelineRow(entry)); + for (let level = 1; level <= 3 && bytes(rows) > budget; level++) + for (let i = 0; i < entries.length && bytes(rows) > budget; i++) { + rows[i] = timelineRow(entries[i], level); + } + // Hundreds of steps: the oldest become one summary line. + let merged = 0; + while (merged < entries.length - 1 && bytes(rows.slice(merged)) + 200 > budget) merged++; + if (!merged) return rows; + const counted = entries.slice(0, merged); + const commands = counted.flatMap((entry) => entry.items || []).length; + const thoughts = counted.filter((entry) => entry.type === "think").length; + const summary = [commands && `运行 ${commands} 条命令`, thoughts && `思考 ${thoughts} 次`].filter(Boolean).join("、"); + return [{ tag: "markdown", content: grey(`第 1–${merged} 步${summary ? `:${summary}` : ""}`) }, ...rows.slice(merged)]; +} + // 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,56 +399,113 @@ function exchangeItems(items) { return items.slice(boundary + 1); } -export function formatTurnCard(status, items = [], error, turn, questions = []) { +const recentRows = 4; + +// Scopes Feishu says the bot's app lacks, from the Agent's tool output: the +// platform's "One of the following scopes is required: [...]", lark-cli's +// "required scope(s): ...", and permission_violations subjects. +export function missingScopes(items) { + const found = new Set(); + const scope = /^[a-z][a-z0-9_]*:[a-z0-9_.:-]+$/; + for (const item of items) { + if (!["command_execution", "function_call_output", "mcp_call"].includes(item.type)) continue; + const output = [item.output, item.error].map((value) => (typeof value === "string" ? value : JSON.stringify(value ?? ""))).join("\n"); + if (!/99991672|99991679|app_scope_not_applied|scopes? (?:is )?required|required scope/i.test(output)) continue; + for (const [, list] of output.matchAll(/(?:scopes is required: \[|required scope\(s\): )([^\]"\n]+)/g)) + for (const name of list.split(/[,\s]+/)) if (scope.test(name)) found.add(name); + for (const [, name] of output.matchAll(/"subject":\s*"([^"]+)"/g)) if (scope.test(name)) found.add(name); + } + return [...found]; +} + +// What to tell people when the bot's app lacks scopes: which, and where to apply. +function scopeNotice(app, scopes) { + const host = app.brand === "lark" ? "open.larksuite.com" : "open.feishu.cn"; + const url = `https://${host}/page/scope-apply?clientID=${encodeURIComponent(app.app_id)}&scopes=${encodeURIComponent(scopes.join(","))}`; + return [ + `需要为机器人「${app.name || app.app_id}」开通:${scopes.map((name) => `\`${name}\``).join("、")}`, + `[申请开通权限](${url})`, + grey("管理员批准并发布新版本后,直接回复这条消息即可继续。"), + ].join("\n\n"); +} + +export function formatTurnCard(status, items = [], error, turn, questions = [], app) { items = exchangeItems(items); 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)) : ""; - 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 = 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") { + 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 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; + 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 subtitle = [current, rows.length ? `${rows.length} 步` : ""].filter(Boolean).join(" · "); + const body = authorizationText ? excerpt(authorizationText, 8000) : ""; + const fitted = fitRows(rows, cardBudget - bytes(body) - 600); + const earlier = fitted.slice(0, -recentRows), shown = fitted.slice(-recentRows); + // 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: earlier, 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. // 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 = []; + // Scopes the Agent's reply names as missing get a link to apply for them, + // under the reply; scopes it only ran into while exploring do not. + const said = [answer, ...questions.map((action) => questionText(action))].join("\n"); + const scopes = app ? missingScopes(items).filter((name) => said.includes(name)) : []; + if (foldContext) elements.push(panel({ title: grey("补充说明"), content: excerpt(context, 4000), block: true })); + if (scopes.length) { + if (rows.length) elements.push(panel({ title: grey(`处理过程 · ${rows.length} 步`), elements: fitRows(rows, cardBudget - bytes(body) - 3000), block: true })); + return statusCard("missing_scopes", [body, scopeNotice(app, scopes)].join("\n\n"), [{ tag: "hr" }, ...elements]); + } + // 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), block: true })); + } + return statusCard(status, body, elements.length ? [{ tag: "hr" }, ...elements] : []); } export async function createFeishuTransport(settings, receive) { @@ -368,6 +534,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", @@ -573,18 +741,21 @@ export function feishuWorker(store, core, product, makeChannel, configId) { if (!message.content?.trim() || message.content.length > 40000) return; const id = stableId(`${appId}:${message.messageId}`); if (store.get("feishu_inbound", id)) return; + const content = asCommand(message.content.trim()); + // A private chat is one thread per sender; /clear starts a new conversation + // in it. The newest one wins if earlier versions left several. let thread = - message.chatType === "p2p" && - !["/new", "/reset"].includes(message.content.trim()) + message.chatType === "p2p" ? store .list("feishu_threads") - .find( + .filter( (row) => row.app_id === appId && row.chat_id === message.chatId && row.sender_id === message.senderId && row.chat_type === "p2p", ) + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] : threadFor(appId, message); if ( thread && @@ -629,7 +800,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { thread_id: threadId, message_id: message.messageId, sender_id: message.senderId, - text: message.content.trim(), + text: content, raw_content_type: message.rawContentType, reply_to_message_id: message.replyToMessageId, occurred_at: message.createTime || stamp(), @@ -667,22 +838,25 @@ export function feishuWorker(store, core, product, makeChannel, configId) { next_at: 0, }); }; - const queueMedia = (thread, items, artifacts = []) => { - const sources = artifacts.map((file) => ({ - id: `artifact:${file.id}`, - path: artifactContentPath(file.session_id, file.id), - name: file.path.split("/").at(-1) || "attachment", - })); - for (const item of items.filter((item) => item.type === "message" && item.role === "assistant")) { - const text = (item.content || []).filter((part) => part.type === "output_text").map((part) => part.text).join("\n"); + // A Turn's files reply to the message it answers. Only files its reply names + // reach the chat; every file stays downloadable in the Web conversation. + const queueMedia = (thread, items, artifacts = [], replyTo = thread.root_id) => { + const replies = items + .filter((item) => item.type === "message" && item.role === "assistant") + .map((item) => ({ item, text: (item.content || []).filter((part) => part.type === "output_text").map((part) => part.text).join("\n") })); + const said = replies.map(({ text }) => text).join("\n"); + const sources = artifacts + .map((file) => ({ file, name: file.path.split("/").at(-1) || "attachment" })) + .filter(({ name }) => said.includes(name)) + .map(({ file, name }) => ({ id: `artifact:${file.id}`, path: artifactContentPath(file.session_id, file.id), name })); + for (const { item, text } of replies) for (const path of qrImages(text)) sources.push({ id: `${item.turn_id}:${path}`, path, name: "feishu-authorization.png" }); - } for (const source of sources) { const id = stableId(`${thread.id}:media:${source.id}`); if (store.get("feishu_outbound", id)) continue; store.transaction(() => { - queueReply(id, thread, ""); + queueReply(id, thread, "", false, replyTo); store.put("feishu_outbound", { ...store.get("feishu_outbound", id), media: source }); }); } @@ -700,7 +874,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { }; const cancel = async (row) => { const thread = store.get("feishu_threads", row.thread_id); - if (!thread) return reject(row, "话题关联不可用,请在 Web 中检查。"); + if (!thread) return reject(row, "这条消息没能接上对话,请重新发送。"); let run = store.get("sessions", thread.session_id); // Resolve an uncertain initial creation before cancelling its actual Session. if (run && !run.core_session_id && !run.deleted_at) @@ -756,10 +930,12 @@ export function feishuWorker(store, core, product, makeChannel, configId) { ((context.resources || []).some((resource) => resource.type === "image") ? "图片消息" : "文件消息"); + // The Agent sees where the message came from, so Skills can key work to it. + const source = feishuSource(row); const media = { images: [], files: [], - text: input.trim() || title, + text: `${source}\n${input.trim() || title}`, title, notice: context.notice, }; @@ -771,8 +947,8 @@ export function feishuWorker(store, core, product, makeChannel, configId) { const chunks = []; for await (const chunk of response) { total += chunk.length; - if (total > 5 * 1024 * 1024) - throw new AppError(413, "附件合计不能超过 5 MiB。"); + if (total > maxUploadBytes) + throw new AppError(413, "附件合计不能超过 50 MiB。"); chunks.push(chunk); } const bytes = Buffer.concat(chunks); @@ -800,11 +976,12 @@ export function feishuWorker(store, core, product, makeChannel, configId) { .replace(/[^a-zA-Z0-9_.-]/g, "_") .slice(-100); const path = `/workspace/inputs/${row.id}-${media.files.length}-${filename}`; - media.files.push({ - type: "inline", - path, - data: bytes.toString("base64"), - }); + // Uploaded once: the message keeps only the File ID across retries. + const form = new FormData(); + form.append("file", new Blob([bytes]), filename); + form.append("purpose", "user_data"); + const file = await core("/v1/files", "POST", form); + media.files.push({ type: "file_id", path, file_id: file.id }); media.text += `\nAttachment: ${path.replace("/workspace/", "./")}`; } } @@ -813,26 +990,12 @@ 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 = () => { + if (!thread) return reject(row, "这条消息没能接上对话,请重新发送。"); + // 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: [ @@ -854,13 +1017,37 @@ 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 run = store.get("sessions", thread.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(() => {}); + // The thread starts its next conversation, unless this /clear opened it. + if (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 = { @@ -907,24 +1094,25 @@ 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。", ); + let steered = false; 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}`, @@ -944,7 +1132,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", @@ -973,7 +1161,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", @@ -1003,8 +1191,11 @@ export function feishuWorker(store, core, product, makeChannel, configId) { row = { ...row, state: "submitting", event }; store.put("feishu_inbound", row); // Messages go through the Turn ledger; answers and cancellations go to Core directly. - if (event.type === "agent.session.input.message") - await ledger.deliver(run, event.input, { id: row.id, key: row.id, source: "feishu" }); + // A message sent while a Turn runs joins it in Core and keeps its card. + if (event.type === "agent.session.input.message") { + const delivered = await ledger.deliver(run, event.input, { id: row.id, key: row.id, source: "feishu" }); + steered = delivered.id !== row.id || delivered.base_running === true; + } else await core( `/v1/agents/sessions/${run.core_session_id}/events`, @@ -1015,11 +1206,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", @@ -1031,13 +1222,14 @@ export function feishuWorker(store, core, product, makeChannel, configId) { } throw error; } - if (row.text === "/cancel") + if (row.text === "/stop") queueReply( stableId(`${row.id}:cancel`), thread, "已请求取消,等待 Agent 确认停止。", ); - else progress(thinking); + // A message that steered the running Turn keeps that Turn's card. + else if (!steered) progress(thinking); } store.put("feishu_inbound", { ...row, @@ -1128,10 +1320,16 @@ 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); + // The bot's app, so a card can link to applying for scopes it lacks. + const app = store.list("feishu_bots").find((bot) => bot.app_id === thread.app_id); + 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) { @@ -1146,8 +1344,8 @@ export function feishuWorker(store, core, product, makeChannel, configId) { const items = currentItems.filter( (item) => item.turn_id === turns.at(-1)?.id && !ledger.superseded(run.id, item.turn_id), ); - queueMedia(thread, items); - const text = formatTurnCard(session.status === "requires_action" && questions.length ? "requires_action" : "in_progress", items, undefined, ledger.summary(run.id), questions); + queueMedia(thread, items, [], progress?.reply_to); + const text = formatTurnCard(session.status === "requires_action" && questions.length ? "requires_action" : "in_progress", items, undefined, ledger.summary(run.id), questions, app); if (progress?.sent_message_id) { if (thread.progress_text !== text) { try { @@ -1200,68 +1398,57 @@ export function feishuWorker(store, core, product, makeChannel, configId) { if (turn.status === "completed" && (thread.answered_call_ids || []).some((callId) => 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); + queueMedia(thread, turnItems, artifacts.filter((file) => file.turn_id === turn.id), card?.reply_to); + // 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, + undefined, + [], + app, ); 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); @@ -1349,7 +1536,7 @@ export function feishuWorker(store, core, product, makeChannel, configId) { ? await product("GET", row.media.path, {}) : await core(row.media.path, "GET"); if (!Buffer.isBuffer(bytes) || !bytes.length || bytes.length > maxMediaBytes) - throw new AppError(413, "附件为空或超过 5 MiB,请在网页会话中下载。"); + throw new AppError(413, "附件为空或超过 5 MiB,飞书发送不了。"); uploaded = await channel.upload(bytes, row.media.name); store.put("feishu_outbound", { ...store.get("feishu_outbound", row.id), uploaded_media: uploaded }); } @@ -1411,6 +1598,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; @@ -1428,7 +1619,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); @@ -1454,7 +1645,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) ) @@ -1495,6 +1686,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/product.mjs b/example/parsar/server/product.mjs index 3257d2fec..576a11303 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, credentialNames, requiredCredentials } from "./capability-kinds.mjs"; import { AppError, text, uuid } from "./store.mjs"; import { harnessOptions, @@ -30,24 +32,29 @@ 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 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); + if (kind === "secrets") return publicSecret(row); if (kind === "sessions") { - const { request, ...record } = row; + const { request, recovery, ...record } = row; return { ...record, turn: sessions.turns.summary(record.id) }; } return row; }; const capabilities = capabilityAPI(store); const oauth = mcpOAuthAPI(store, core, origin, fetchImpl); - const feishu = feishuAPI(store); + const feishu = feishuAPI(store, fetchImpl); 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)); @@ -136,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/")) @@ -201,21 +218,16 @@ 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) || + (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(() => { store.remove(kind, id); if (kind === "models") touchProviders(previous?.provider_id); @@ -264,34 +276,13 @@ 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 === "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)); @@ -349,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 ( @@ -380,12 +390,30 @@ 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]; 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 +427,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 50d200a41..22f39a946 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"; @@ -64,26 +65,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 个。"); @@ -110,7 +91,8 @@ export function runtimeProfile(body, previous) { setup_script: text(body.setup_script || "", "初始化脚本", 100000), ...(network && { network }), 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, }; @@ -122,22 +104,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: [] }, - ...(runtime.network && { network: runtime.network }), - 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..2ec623960 100644 --- a/example/parsar/server/sessions.mjs +++ b/example/parsar/server/sessions.mjs @@ -1,93 +1,37 @@ 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 { boundBot, contributions } from "./capability-kinds.mjs"; +import { frozenConfiguration, 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 botEnv = Object.entries(runtime.bot_env || {}); - const bot = sessionBot(store, agent.id); - if (botEnv.length && !bot?.app_secret) - throw new AppError( - 400, - "环境模板需要飞书机器人身份,请先在飞书接入中把机器人绑定到这个 Agent。", - ); - const environment = runtimeEnvironment(runtime); - for (const [name, field] of botEnv) - environment.env[name] = field === "brand" ? bot.brand || "feishu" : bot[field]; + const bound = contributions(store, 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( 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 ( @@ -101,81 +45,45 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { JSON.stringify(images).length > 900000 ) throw new AppError(400, "图片格式或大小无效。"); - if (images.length && agent.harness === "mcode") - throw new AppError( - 400, - "当前运行配置不支持图片输入,请使用托管 Codex 或 Claude Code。", - ); + if (images.length && !harnessOptions[agent.harness].message_images) + throw new AppError(400, `${harnessOptions[agent.harness].label} 不支持图片输入,请换用支持图片的执行引擎。`); if ( !Array.isArray(files) || files.length > 8 || - JSON.stringify(files).length > 7000000 || files.some( (file) => - file.type !== "inline" || + file.type !== "file_id" || !/^\/workspace\/inputs\/[a-zA-Z0-9_.-]+$/.test(file.path) || - typeof file.data !== "string" || - !/^[A-Za-z0-9+/]*={0,2}$/.test(file.data), + !/^file-[A-Za-z0-9_-]+$/.test(file.file_id), ) ) 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]; + // 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, + ...(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] } : {}), + }; + // The same configuration inline, for replacing this conversation's Session. + const frozen = frozenConfiguration(store, agent, runtime); + const inlineEnvironment = { + type: "openai_hosted", + ...frozen.template, + env: { ...frozen.template.env, ...env }, + skills: bound.session.skills, + plugins: bound.session.plugins, + ...(environment.files ? { files: environment.files } : {}), + }; const name = warm ? "" : text(body.name, "会话名称", 80, true); const input = warm ? "" : text(body.input, "消息", 100000, true); - return { + const row = { id, name, agent_id: agent.id, @@ -185,64 +93,27 @@ 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, 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), - 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 +132,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, @@ -270,9 +148,11 @@ export function prepareSession(store, id, body, origin = {}, warm = false) { }, }, }; + const { agent_id: _saved, ...shared } = row.request; + return { ...row, recovery: { ...shared, agent: frozen.agent, environment: inlineEnvironment } }; } -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 +160,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 +177,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 +239,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 +258,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 15db2e458..f05bac6d1 100644 --- a/example/parsar/server/turns.mjs +++ b/example/parsar/server/turns.mjs @@ -90,12 +90,34 @@ export function turnsAPI(store, core) { return turn; }; + // An input sent while a Turn ran joined that Turn in Core. It is recorded on + // the entry that owns that Turn instead of as a Turn of its own, and resent + // with it if that Turn is retried. + const join = (turn, coreTurnId) => { + const owner = store + .list("turns") + .find((t) => t.id !== turn.id && t.session_id === turn.session_id && t.started_at <= turn.started_at && + (t.core_turn_id ? t.core_turn_id === coreTurnId : t.outcome === "running")); + if (!owner) return turn; + store.transaction(() => { + store.put("turns", { ...owner, steers: [...(owner.steers || []), { id: turn.id, input: turn.input }] }); + store.remove("turns", turn.id); + }); + return store.get("turns", owner.id); + }; + // Records and sends a follow-up input. The entry is written first so a - // retried send (same id and key) keeps its original baseline. + // retried send (same id and key) keeps its original baseline. Returns the + // Turn the input is part of. const deliver = async (record, input, { id, key, source }) => { + const joined = store.list("turns").find((t) => t.steers?.some((steer) => steer.id === id)); + if (joined) return joined; const turn = store.get("turns", id) || start(record, { id, kind: "followup", input, source, base: await baseline(record.core_session_id) }); + // A confirmation comes later; by then Core may have started a Turn for it, + // so only the monitor's observation can attribute it. + const confirming = !!turn.pending_key; if (source === "web") store.put("turns", { ...turn, pending_key: key }); try { await post(record.core_session_id, message(turn.input), key); @@ -110,7 +132,8 @@ export function turnsAPI(store, core) { // Web confirms receipt with the same key; Feishu owns its delivery retries. if (source !== "web") throw error; } - return store.get("turns", id); + const current = store.get("turns", id); + return current.pending_key || confirming || !current.base_running ? current : join(current, current.base_turn_id); }; // The first input of a claimed prepared Session. Core answers the send only @@ -129,7 +152,7 @@ export function turnsAPI(store, core) { () => { const turn = store.get("turns", id); if (turn?.outcome === "running" && turn.core_session_id === record.core_session_id) - store.put("turns", { ...turn, unsent: true }); + save({ ...turn, unsent: true }); }, ); return record; @@ -145,25 +168,35 @@ export function turnsAPI(store, core) { return deliver(record, text, { id: `${sessionId}:${key}`, key, source: "web" }); }; + // The monitor writes back a copy it read before its Core calls; a message + // that joined the Turn meanwhile stays recorded. + const save = (turn) => { + const steers = store.get("turns", turn.id)?.steers; + const saved = steers ? { ...turn, steers } : turn; + store.put("turns", saved); + return saved; + }; + const finish = (turn, outcome, failure) => { const done = { ...turn, outcome, stage: undefined, done_at: now(), pending_cancel: undefined }; if (failure) done.failure = failure; if (outcome === "completed" && !done.output_at) done.output_at = done.done_at; - store.put("turns", done); - return done; + return save(done); }; // Replaces a Session whose first Turn never ran (its Environment failed, or a - // claimed prepared Session did not start) with the identical frozen request. + // claimed prepared Session did not start) with the configuration it was created with. const recreate = async (turn, record, key, retries) => { - const created = await core("/v1/agents/sessions", "POST", record.request, key); + // The configuration frozen when the conversation was created, not the + // saved Agent and Template, which may have been edited since. + const created = await core("/v1/agents/sessions", "POST", record.recovery, key); store.transaction(() => { const { pending_input, ...rest } = record; store.put("sessions", { ...rest, core_session_id: created.id, superseded_turns: [] }); // Feishu cursors belong to the replaced Core Session. for (const thread of store.list("feishu_threads").filter((t) => t.session_id === record.id)) store.put("feishu_threads", { ...thread, turn_cursor: undefined, item_cursor: undefined, artifact_cursor: undefined }); - store.put("turns", { ...turn, core_session_id: created.id, retries, prepared: false, unsent: undefined, stage: "preparing" }); + save({ ...turn, core_session_id: created.id, retries, prepared: false, unsent: undefined, stage: "preparing" }); }); await core(`/v1/agents/sessions/${record.core_session_id}`, "DELETE").catch(() => {}); }; @@ -177,26 +210,31 @@ export function turnsAPI(store, core) { ...record, superseded_turns: [...new Set([...(record.superseded_turns || []), coreTurnId])], }); - await post(record.core_session_id, message(turn.input), `${turn.id}:resend:${retries}`); - store.put("turns", { ...turn, retries, stage: "retrying", pending_cancel: undefined, core_turn_id: undefined }); + // Messages that steered the Turn go with it again. + const input = [...asInput(turn.input), ...(turn.steers || []).flatMap((steer) => steer.input)]; + await post(record.core_session_id, message(input), `${turn.id}:resend:${retries}`); + save({ ...turn, retries, stage: "retrying", pending_cancel: undefined, core_turn_id: undefined }); }; const check = async (turn) => { const record = store.get("sessions", turn.session_id); if (!record || record.deleted_at) return finish(turn, "cancelled"); if (record.core_session_id !== turn.core_session_id) { - store.put("turns", { ...turn, core_session_id: record.core_session_id }); + save({ ...turn, core_session_id: record.core_session_id }); return; } // Confirm uncertain Web submissions before attributing any Core Turn to // the input. Reusing the frozen body and key cannot execute it twice. if (turn.pending_key) { + let confirmed; try { - turn = await deliver(record, turn.input, { id: turn.id, key: turn.pending_key, source: "web" }); + confirmed = await deliver(record, turn.input, { id: turn.id, key: turn.pending_key, source: "web" }); } catch (error) { return finish(turn, "failed", explainFailure(error, runtimeName(record))); } - if (turn.pending_key) return; + // Still unconfirmed, or confirmed as part of the Turn that was running. + if (confirmed.pending_key || confirmed.id !== turn.id) return; + turn = confirmed; } const sid = turn.core_session_id; const session = await core(`/v1/agents/sessions/${sid}`, "GET"); @@ -208,6 +246,8 @@ export function turnsAPI(store, core) { const at = turn.base_turn_id ? recent.findIndex((t) => t.id === turn.base_turn_id) : -1; const coreTurn = recent.slice(at + 1).find((t) => !superseded.has(t.id)) || (turn.base_running && at >= 0 ? recent[at] : undefined); + // The input joined the Turn that was running when it was sent. + if (coreTurn && coreTurn.id === turn.base_turn_id && join(turn, coreTurn.id).id !== turn.id) return; if (!coreTurn) { const replaceable = turn.kind === "first" && record.request && turn.retries < maxRetries; const notStarted = turn.prepared && (turn.unsent || now() - turn.started_at > stallMs); @@ -227,7 +267,7 @@ export function turnsAPI(store, core) { current.output_at = now(); // The frozen request is only needed until the first Turn produced output. if (record.request) { - const { request, ...rest } = record; + const { request, recovery, ...rest } = record; store.put("sessions", rest); } } @@ -252,7 +292,7 @@ export function turnsAPI(store, core) { await post(sid, { type: "agent.session.input.cancel" }, `${turn.id}:cancel:${current.retries + 1}`); current = { ...current, pending_cancel: coreTurn.id, stage: "retrying" }; } else if (stalled) current.failure = unresponsive; - store.put("turns", current); + save(current); }; let monitoring = false; @@ -260,10 +300,14 @@ export function turnsAPI(store, core) { if (monitoring) return; monitoring = true; try { - for (const turn of store.list("turns").filter((t) => t.outcome === "running")) - await check(turn).catch(() => { - /* Core may be briefly unreachable; the next pass retries. */ - }); + for (const { id } of store.list("turns").filter((t) => t.outcome === "running")) { + // Read each entry afresh: checking an earlier one can fold an input into it. + const turn = store.get("turns", id); + if (turn?.outcome === "running") + await check(turn).catch(() => { + /* Core may be briefly unreachable; the next pass retries. */ + }); + } } finally { monitoring = false; } @@ -282,7 +326,6 @@ export function turnsAPI(store, core) { started_at: turn.started_at, done_at: turn.done_at, elapsed_s, - expected_s, first_output_s: turn.output_at ? Math.round((turn.output_at - turn.started_at) / 100) / 10 : null, slow: turn.outcome === "running" && !turn.output_at && elapsed_s > Math.max(2 * expected_s, expected_s + 15), retries: turn.retries, @@ -314,6 +357,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, @@ -374,6 +418,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/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/shared/feishu-source.d.mts b/example/parsar/shared/feishu-source.d.mts new file mode 100644 index 000000000..4f8823d5d --- /dev/null +++ b/example/parsar/shared/feishu-source.d.mts @@ -0,0 +1,2 @@ +export function feishuSource(row: { message_id: string; chat_id: string; chat_type: string; sender_id: string }): string; +export function withoutFeishuSource(text: string): string; diff --git a/example/parsar/shared/feishu-source.mjs b/example/parsar/shared/feishu-source.mjs new file mode 100644 index 000000000..36b4af324 --- /dev/null +++ b/example/parsar/shared/feishu-source.mjs @@ -0,0 +1,6 @@ +// The line that tells an Agent which Feishu message it is answering. Parsar's +// own views show the message without it. +export const feishuSource = (row) => + `[飞书消息 message_id=${row.message_id} chat_id=${row.chat_id} chat_type=${row.chat_type} sender_open_id=${row.sender_id}]`; + +export const withoutFeishuSource = (text) => text.replace(/^\[飞书消息 message_id=\S+ chat_id=\S+ chat_type=\S+ sender_open_id=\S+\]\n/, ""); diff --git a/example/parsar/shared/harness-catalog.mjs b/example/parsar/shared/harness-catalog.mjs new file mode 100644 index 000000000..d3a59ca56 --- /dev/null +++ b/example/parsar/shared/harness-catalog.mjs @@ -0,0 +1,96 @@ +// Code generated by scripts/generate-harness-catalog.py; DO NOT EDIT. + +// What each built-in Harness declares in internal/harnessconfig//declaration.go. +export const harnessCatalog = { + "claude_sdk": { + "label": "Claude Code", + "protocols": [ + "anthropic" + ], + "reasoning_effort": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "reasoning_summary": [ + "auto" + ], + "web_search": [ + "disabled", + "live" + ], + "verbosity": [ + "medium" + ], + "service_tier": [], + "message_images": true, + "mcp_http_required": true + }, + "codex": { + "label": "Codex", + "protocols": [ + "responses" + ], + "reasoning_effort": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "reasoning_summary": [ + "concise", + "detailed", + "auto" + ], + "web_search": [ + "disabled", + "cached", + "live" + ], + "verbosity": [ + "low", + "medium", + "high" + ], + "service_tier": [ + "default", + "flex", + "priority", + "fast" + ], + "message_images": true, + "mcp_http_required": true + }, + "mcode": { + "label": "MiniMax Code", + "protocols": [ + "anthropic", + "responses", + "chat_completions" + ], + "reasoning_effort": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "reasoning_summary": [], + "web_search": [ + "disabled" + ], + "verbosity": [ + "medium" + ], + "service_tier": [], + "message_images": false, + "mcp_http_required": false + } +}; diff --git a/example/parsar/shared/harness-options.d.mts b/example/parsar/shared/harness-options.d.mts index 8dc722e1b..42d7dc5f0 100644 --- a/example/parsar/shared/harness-options.d.mts +++ b/example/parsar/shared/harness-options.d.mts @@ -1,4 +1,6 @@ -export type HarnessKind = "codex" | "claude_sdk" | "mcode"; +import type { CoreHarnessKind, ModelProviderProtocol } from "@oac/agents-client"; + +export type HarnessKind = CoreHarnessKind; export type ExecutionOptionField = | "reasoning_effort" | "reasoning_summary" @@ -7,10 +9,13 @@ export type ExecutionOptionField = | "service_tier"; export const harnessOptions: Record< HarnessKind, - { label: string; protocol: "responses" | "anthropic" } & Record< - ExecutionOptionField, - string[] - > + { + label: string; + protocol: ModelProviderProtocol; + protocols: ModelProviderProtocol[]; + message_images: boolean; + mcp_http_required: boolean; + } & Record >; export const executionOptionFields: ExecutionOptionField[]; export function unsupportedOption( diff --git a/example/parsar/shared/harness-options.mjs b/example/parsar/shared/harness-options.mjs index 93eae7ef7..e99efc8e6 100644 --- a/example/parsar/shared/harness-options.mjs +++ b/example/parsar/shared/harness-options.mjs @@ -1,35 +1,11 @@ -// Execution options each Harness admits, mirroring Core's Harness declarations -// (contracts/agents-api/harness-capabilities.md). An empty value means the -// Harness default and is never sent. -export const harnessOptions = { - codex: { - label: "Codex", - protocol: "responses", - reasoning_effort: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - reasoning_summary: ["concise", "detailed", "auto"], - web_search: ["disabled", "cached", "live"], - verbosity: ["low", "medium", "high"], - service_tier: ["default", "flex", "priority"], - }, - claude_sdk: { - label: "Claude Code", - protocol: "anthropic", - reasoning_effort: ["low", "medium", "high", "xhigh", "max"], - reasoning_summary: [], - web_search: ["disabled", "live"], - verbosity: ["medium"], - service_tier: [], - }, - mcode: { - label: "MiniMax Code", - protocol: "anthropic", - reasoning_effort: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - reasoning_summary: [], - web_search: ["disabled"], - verbosity: ["medium"], - service_tier: [], - }, -}; +import { harnessCatalog } from "./harness-catalog.mjs"; + +// Execution options each Harness admits, as Core's Harness declarations state +// them. An empty value means the Harness default and is never sent. Parsar +// connects a Provider with the Harness's first declared protocol. +export const harnessOptions = Object.fromEntries( + Object.entries(harnessCatalog).map(([kind, declared]) => [kind, { ...declared, protocol: declared.protocols[0] }]), +); export const executionOptionFields = [ "reasoning_effort", diff --git a/example/parsar/shared/media.mjs b/example/parsar/shared/media.mjs index 5b566e5e4..fa2fd7351 100644 --- a/example/parsar/shared/media.mjs +++ b/example/parsar/shared/media.mjs @@ -1,6 +1,9 @@ // Only persisted product QR images and public Session Artifacts are media sources. export const qrImagePattern = /^\/app\/feishu\/provision\/([a-f0-9-]{36})\/qrcode$/; export const maxMediaBytes = 5 * 1024 * 1024; +// Files people hand to an Agent go to Core's Files API, then into the +// workspace by File ID, which Core takes up to 50 MiB. +export const maxUploadBytes = 50 * 1024 * 1024; export const artifactContentPath = (sessionId, artifactId) => `/v1/agents/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/content`; export function qrImages(text) { diff --git a/example/parsar/shared/turn-status.d.mts b/example/parsar/shared/turn-status.d.mts index 3938f69a5..63882af55 100644 --- a/example/parsar/shared/turn-status.d.mts +++ b/example/parsar/shared/turn-status.d.mts @@ -1,3 +1,2 @@ -export const typicalWait: string; export function waitingHeadline(turn?: { stage?: string; slow?: boolean }): string; export function stageLabel(turn?: { stage?: string; slow?: boolean }): string; diff --git a/example/parsar/shared/turn-status.mjs b/example/parsar/shared/turn-status.mjs index ac1bc5999..c3f6f929d 100644 --- a/example/parsar/shared/turn-status.mjs +++ b/example/parsar/shared/turn-status.mjs @@ -1,7 +1,5 @@ // What a conversation shows about a running Turn. Web and Feishu cards read // the same copy; Web translates it, Feishu shows it as is. -export const typicalWait = "通常约 {{value0}} 秒"; - // Before the first output: where the time goes. export function waitingHeadline(turn) { if (turn?.stage === "retrying") return "响应较慢,正在自动重试"; 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 ( + +