mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(managed-agent): wire static Skills and extension configuration (#14319)
Connect published Skill resources and administrator-provisioned MCP/Hook pins to immutable Managed Agent sessions, with on-demand loading and durable recovery. Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
c3012a523f
commit
b5a266e1ed
45 files changed
+3066
-130
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# Managed Agent extension configuration and static Skills
|
||||
|
||||
[English](2026-10-15-managed-agent-extension-config.md) | [简体中文](2026-10-15-managed-agent-extension-config.zh-CN.md)
|
||||
|
||||
Status: implemented and locally verified. Initial implementation baseline: `e612bbf23c`; review recovery fix rebased onto `55fbf99003`.
|
||||
|
||||
## Problem and baseline behavior
|
||||
|
||||
Managed MCP and Hooks already have durable private Hosted implementations. Public AgentDefinitions store `skills` and `mcp_servers`, but Session admission rejects nonempty values. Hook catalogs have no AgentDefinition field. Hosted deliberately skips the local SkillManager, preventing skills installed on the Harness host from leaking into a remote Session.
|
||||
|
||||
This change connects explicitly published extensions to the existing Session definition. It retains one model driver in the Harness and keeps MCP credentials, Hook recipes, command execution, and Workspace files in the Tool Runtime.
|
||||
|
||||
## Scope and configuration
|
||||
|
||||
The first supported Skill is a static, model-invocable instruction resource. An AgentDefinition publishes its name, description, and Markdown body. The immutable AgentDefinition revision fixes these bytes; editing a definition affects new Sessions only. A Skill may identify a base directory relative to the Session's saved working directory inside its Workspace, matching the existing file tools' path contract. The Harness never interprets that directory as a path on its own host. Scripts and attachments remain Workspace resources accessed through the selected Runtime's existing tools and approval policy.
|
||||
|
||||
MCP definitions contain only `server_id`, `server_revision`, and `definition_digest` pins selecting an administrator-provisioned Runtime manifest. They require the existing MCP tool profile and deployment allowlist. A Hook catalog similarly contains `catalog_id`, `catalog_revision`, and `definition_digest`. It requires a Workspace-bound tool profile. Neither supported pin shape accepts endpoints, credentials, commands, module paths, or execution recipes.
|
||||
|
||||
Skills require a Workspace-bound tool profile. They do not initialize local SkillManager discovery. Empty or omitted extension fields preserve existing behavior for non-MCP profiles. Invalid, duplicate, oversized, incompatible, or unsupported definitions fail admission with a field-specific error. Nonempty MCP pins without the MCP profile, and an MCP profile without pins, are rejected.
|
||||
|
||||
Each Skill accepts exactly `name`, `description`, `body`, and optional `base_directory` (default `.`). Names follow the native Skill character vocabulary and are limited to 128 UTF-8 bytes. A catalog holds at most 16 unique names and 48 KiB of JSON; descriptions are limited to 4 KiB and bodies to 32 KiB. Directories are limited to 4 KiB and use canonical relative POSIX segments, without traversal, backslashes, colons, or control characters. Invalid Unicode and blank required values are rejected without changing the published body bytes.
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "repository-review",
|
||||
"description": "Review changes using the project's review guide.",
|
||||
"body": "Read review-guide.md and apply its checklist to the current diff.",
|
||||
"base_directory": "docs"
|
||||
}
|
||||
],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"server_id": "repository-tools",
|
||||
"server_revision": 1,
|
||||
"definition_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
],
|
||||
"hook_catalog": {
|
||||
"catalog_id": "repository-policy",
|
||||
"catalog_revision": 1,
|
||||
"definition_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These fields are part of an otherwise valid AgentDefinition. This combined example requires its `tools` field to select the allowlisted MCP profile.
|
||||
|
||||
## Ownership and execution
|
||||
|
||||
| Component | Responsibility |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Java control plane | Validate the supported configuration, pin the AgentDefinition revision/digest at Session creation, and publish immutable Skill content resources when creating the Hosted attachment. |
|
||||
| Hosted Session | Save Skill references, MCP server pins, and Hook catalog pins in `managed-definition`; recover from those saved values and reject conflicting requests. |
|
||||
| Harness | Advertise the explicit Skill catalog and a `skill` tool. Load the selected immutable body into the tool response without touching the local filesystem. Continue the existing MCP and Hook orchestration. |
|
||||
| Runtime Broker and Tool Runtime | Preserve existing authenticated Session routing, Workspace ownership, MCP connection and credential handling, Hook execution, and receipt/drain semantics. |
|
||||
|
||||
The Skill tool uses the existing assistant/tool-result journal. Loading immutable instructions has no external side effect and grants no permissions. A successful response is committed before the next model request. Recovery can reuse a committed response or recompute it from the same immutable resource; it cannot execute a script, install a Hook, change the model, or grant tool access. A new `skill_activation` state machine is unnecessary for this restricted instruction-only operation.
|
||||
|
||||
When an independent Hook catalog applies to a Skill call, recovery reads the original committed PreToolUse aggregate, preserving its refusal or effective input. A missing execution marker proves dispatch never started: recovery journals a never-ran error without exposing the Skill body or executing Hooks. A marker with an unfinished or missing result still blocks recovery. If the Hook writer stripped an oversized input, recovery verifies its saved semantic input digest against the original tool, arguments, call, prompt and pinned approval mode before replaying the committed byte-limit refusal. Instruction-only Skill loading does not create approval waits; a PreToolUse `ask` returns an explicit unsupported-approval refusal instead of granting access. Existing post-tool Hook recovery still runs before model continuation.
|
||||
|
||||
Later transactions that reference a saved definition include its nested instructions and Skill references in their bounded resource census, even after the initial staging bytes have been released. The server accepts these two immutable published inline resource kinds when validating that closure, retaining the Session scope, schema, byte limits and digest checks.
|
||||
|
||||
Skill discovery and validation use the same saved catalog. Unlisted skills and unsupported arguments are refused. Bodies and result envelopes must fit the existing Session Store limits. Skill descriptions remain bounded and tool names cannot shadow built-in tools. Relative asset paths resolve from the Session's saved working directory within the selected Workspace; absolute paths, traversal, and ambiguous roots are rejected at configuration admission.
|
||||
|
||||
Skills load in their own tool batch before Workspace tools run. A mixed batch is refused before PermissionRequest Hooks, approval Actions, or Workspace tool dispatch, so a static instruction read cannot enter the existing file-only approval continuation format. Recovery derives the same refusal from the original assistant batch. Existing Session initialization and PostToolBatch Hooks retain their normal behavior. A pure batch may load several Skills.
|
||||
|
||||
## Recovery, lifecycle, and rollout
|
||||
|
||||
The existing AgentDefinition revision and digest remain the public source of identity. Load requests carry that original identity and use the saved resource references rather than resolving the latest definition. Skills survive a Harness replacement through their durable content references. Missing or corrupt resources block loading instead of silently dropping skills.
|
||||
|
||||
MCP and Hook side effects retain their original stable operation IDs and Runtime owners. Unknown outcomes are observed, never resent as new work. Existing MCP/Hook close and drain checks remain mandatory. Enabling the MCP profile still requires the deployment's explicit profile allowlist and compatible Hosted lifecycle capability; this change does not enable it globally or bypass an upgrade gate.
|
||||
|
||||
Deploy compatible Harness and Runtime components before admitting new configured Sessions. Existing Sessions without extension fields remain readable for non-MCP profiles. The MCP profile now requires nonempty server pins during create and attach/load validation. Earlier storage admission could retain an MCP profile with empty pins, although Hosted attachment already rejected it. Do not assume such rows are absent: audit stored definitions and Sessions before rollout, retain historical access separately, and settle or close affected Sessions with the previous deployment before replacing it. Publishing a corrected definition revision enables new Sessions; it does not rewrite existing Session pins. Old Harness processes may reject the newly published configuration, so admission failures remain visible and must not be reported as successful installation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
This is the first static published Skill path. It does not implement project/user/extension filesystem discovery, live catalog updates, slash commands, path-triggered activation, Skill `allowedTools`, Skill-registered Hooks, model overrides, or argument-file expansion. Those require the broader C07 activation contract and remain rejected rather than silently ignored. Explicit AgentDefinition Hook catalogs are independent of Skill activation. This change adds no Environment template, package installer, credentials API, Runtime sharing, or idle reclamation policy.
|
||||
|
||||
## Affected areas and validation
|
||||
|
||||
Changes cover AgentDefinition validation and its OpenAPI schema, the Java Hosted connector, immutable Hosted definition parsing and loading, and the Hosted Skill tool. The existing MCP/Hook implementations are reused.
|
||||
|
||||
Run a global-CLI baseline before implementation and record the public admission gap separately from the ordinary CLI's existing Skill support. Test valid and invalid definitions, resource publication, first Skill invocation, unknown Skill refusal, resource corruption, conflicting load, Harness detach/load, MCP/Hook pin forwarding, and MCP close capability gates. Use deterministic model/MCP fixtures to prove the model sees and invokes the configured capability without host-settings discovery. Run focused TypeScript and Java tests, build/typecheck/bundle, the applicable Java verification profiles, and two clean full-diff audits. Record unavailable physical environments and never equate fixture results with production acceptance.
|
||||
|
||||
Initial implementation build, typecheck, bundle, lint and formatting passed, alongside 515 Hosted Session tests, 337 Skill/Hook/tool/recovery tests, 50 model/definition tests, 77 Java SDK tests and the Java server verification suite (2897 tests, seven skipped). Both tests in the selected Hosted Harness/MySQL 8.4 profile passed against the final bundle. The full MySQL profile first passed 548 of 549 tests; its existing asynchronous verification timing case passed an isolated rerun. This does not make the original full run green.
|
||||
|
||||
The final packaged-process fixture made 18 deterministic model requests, verifying actual call names and arguments against durable history. It covered on-demand loading, invalid invocations, immutable cold loads, corrupt/missing resources, normal mixed-batch refusals and a pure-Skill SIGKILL followed by HTTP recovery. Mixed-batch SIGKILL loads remained explicitly blocked with HTTP 409; a separate built recovery-helper check on the same real journal verified the immutable refusal and idempotency, without claiming HTTP continuation. No Runtime execution occurred in this fixture. Physical Linux execution, real MCP/Hook effects and production acceptance are not established by these results.
|
||||
|
||||
After rebasing the review recovery fix onto `55fbf99003`, build/typecheck/bundle and changed-file lint/formatting passed, together with 392 targeted TypeScript tests, 31 focused Java recovery tests, ten Workspace recovery/MySQL tests, two Hosted Harness/MySQL tests and SpotBugs. The full Java test phase passed 2923 tests with seven skipped, but `mvn verify` failed on eleven pre-existing Checkstyle violations in the unchanged CSI SQL worker test from that base; the failing file, ruleset and plugin configuration exactly match the base. Independent reader probes verified both fixes, and the rebuilt 18-request process fixture retained the same recovery boundaries described above.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. A published static Skill is visible and callable in a new Managed Session, and its body is part of durable model history.
|
||||
2. Reloading that Session preserves the original Skill bytes and extension pins even after a newer AgentDefinition revision exists.
|
||||
3. An explicitly admitted MCP configuration and Hook catalog reach the existing Runtime implementations without exposing recipes or credentials.
|
||||
4. Unsupported options, missing resources, profile incompatibility, and conflicting pins produce explicit failures before affected work runs.
|
||||
5. Existing Sessions and their approval, recovery, close, and drain behavior remain covered by regression checks.
|
||||
|
||||
## Open questions
|
||||
|
||||
None for this bounded slice. Dynamic Skill activation and discovery remain separate work under the existing configuration design.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Managed Agent 扩展配置与静态 Skill
|
||||
|
||||
[English](2026-10-15-managed-agent-extension-config.md) | [简体中文](2026-10-15-managed-agent-extension-config.zh-CN.md)
|
||||
|
||||
状态:已实现并完成本地验证。初始实现基线:`e612bbf23c`;审查后的恢复修复已 rebase 到 `55fbf99003`。
|
||||
|
||||
## 问题与基线行为
|
||||
|
||||
Managed MCP 和 Hook 已有可持久恢复的私有 Hosted 实现。公开 AgentDefinition 可以保存 `skills` 和 `mcp_servers`,但创建 Session 时拒绝非空值。Hook catalog 尚无 AgentDefinition 字段。Hosted 有意跳过本地 SkillManager,防止 Harness 主机安装的技能泄漏到远端 Session。
|
||||
|
||||
本次把显式发布的扩展接到已有 Session 定义上。Harness 仍是唯一模型驱动者,MCP 凭据、Hook recipe、命令执行和 Workspace 文件仍属于 Tool Runtime。
|
||||
|
||||
## 范围与配置
|
||||
|
||||
首个支持的 Skill 是模型可调用的静态指令资源。AgentDefinition 发布名称、描述和 Markdown 正文。不可变的 AgentDefinition revision 固定这些字节,修改定义只影响新 Session。Skill 可以指定相对 Session 在 Workspace 内保存的工作目录的基目录,与已有文件工具的路径契约一致。Harness 绝不把这个目录当作自身主机上的路径。脚本和附件仍是 Workspace 资源,通过选定 Runtime 的既有工具和审批策略访问。
|
||||
|
||||
MCP 定义只包含 `server_id`、`server_revision` 和 `definition_digest` pin,用于选择管理员预置的 Runtime manifest。它要求既有 MCP 工具 profile 和部署白名单。Hook catalog 同样只包含 `catalog_id`、`catalog_revision` 和 `definition_digest`,要求绑定 Workspace 的工具 profile。这两个受支持的 pin 形状都不接受 endpoint、凭据、命令、模块路径或执行 recipe。
|
||||
|
||||
Skill 要求绑定 Workspace 的工具 profile,不初始化本地 SkillManager 发现。对于非 MCP profile,扩展字段为空或省略时保留原行为。非法、重复、超限、不兼容或尚不支持的定义,在准入时返回明确指出字段的错误。非空 MCP pin 没有搭配 MCP profile,以及 MCP profile 没有提供 pin,均拒绝。
|
||||
|
||||
每项 Skill 只接受 `name`、`description`、`body` 和可选的 `base_directory`(默认 `.`)。名称沿用原生 Skill 字符集合,上限为 128 UTF-8 字节。一个 catalog 最多包含 16 个不同名称,JSON 总量不超过 48 KiB;描述上限 4 KiB,正文上限 32 KiB。目录上限 4 KiB,使用规范相对 POSIX 分段,不允许目录穿越、反斜杠、冒号或控制字符。拒绝非法 Unicode 和必填字段的空白值,同时不改写已发布正文的字节。
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "repository-review",
|
||||
"description": "Review changes using the project's review guide.",
|
||||
"body": "Read review-guide.md and apply its checklist to the current diff.",
|
||||
"base_directory": "docs"
|
||||
}
|
||||
],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"server_id": "repository-tools",
|
||||
"server_revision": 1,
|
||||
"definition_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
],
|
||||
"hook_catalog": {
|
||||
"catalog_id": "repository-policy",
|
||||
"catalog_revision": 1,
|
||||
"definition_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这些字段属于一份其他部分合法的 AgentDefinition。这个组合示例要求其 `tools` 字段选择部署白名单中的 MCP profile。
|
||||
|
||||
## 归属与执行
|
||||
|
||||
| 组件 | 职责 |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Java 控制面 | 校验已支持的配置,创建 Session 时固定 AgentDefinition revision/digest,在创建 Hosted attachment 时发布不可变 Skill 内容资源。 |
|
||||
| Hosted Session | 把 Skill 引用、MCP server pin 和 Hook catalog pin 保存在 `managed-definition` 中;从保存值恢复,并拒绝冲突请求。 |
|
||||
| Harness | 展示显式 Skill catalog 和 `skill` 工具。把选定的不可变正文加载到工具响应中,不访问本地文件系统。沿用已有 MCP 和 Hook 编排。 |
|
||||
| Runtime Broker 与 Tool Runtime | 保持既有认证 Session 路由、Workspace 所有权、MCP 连接和凭据管理、Hook 执行以及回执和排空语义。 |
|
||||
|
||||
Skill 工具使用已有 assistant/tool-result 日志。加载不可变指令没有外部副作用,也不授予权限。成功响应在下一次模型请求前提交。恢复可以复用已提交响应,或从同一不可变资源重新计算;它不能执行脚本、安装 Hook、切换模型或授予工具权限。对于这个仅加载指令的受限操作,无须新建 `skill_activation` 状态机。
|
||||
|
||||
Skill 调用受独立 Hook catalog 约束时,恢复读取原先已提交的 PreToolUse 聚合回执,保留其拒绝或有效输入。执行标记缺失证明派发尚未开始:恢复把未执行错误写入日志,不暴露 Skill 正文,也不执行 Hook。标记存在但结果缺失或尚未结算时,仍阻塞恢复。Hook 写入方因输入过大而精简输入时,恢复用原工具、参数、调用、提示和固定的审批模式校验已保存的语义输入摘要,再重放已提交的字节上限拒绝。仅加载指令的 Skill 不创建审批等待;PreToolUse 返回 `ask` 时明确拒绝不支持的审批要求,不能自动授权。继续模型前仍执行已有后置工具 Hook 恢复。
|
||||
|
||||
后续事务引用已保存定义时,须把其中嵌套的指令与 Skill 引用纳入有界资源清单,即使初次暂存的字节已经释放。服务端校验该闭包时接受这两种不可变的已发布内联资源,同时保留 Session 作用域、schema、字节上限和摘要检查。
|
||||
|
||||
Skill 发现和校验使用同一份已保存的 catalog。不在清单中的技能和不支持的参数均拒绝。正文和结果信封必须符合已有 Session Store 限制。Skill 描述有界,工具名称不能覆盖内置工具。附件相对路径从选定 Workspace 内 Session 保存的工作目录解析,配置准入时拒绝绝对路径、目录穿越和有歧义的根。
|
||||
|
||||
Skill 在独立工具批次中先加载,再执行 Workspace 工具。混合批次在 PermissionRequest Hook、审批 Action 或 Workspace 工具派发前被拒绝,避免静态指令读取进入既有仅支持文件工具的审批续行格式。恢复也从原 assistant 批次推导同一拒绝。既有 Session 初始化和 PostToolBatch Hook 保持原有行为。纯 Skill 批次可以加载多个技能。
|
||||
|
||||
## 恢复、生命周期与发布
|
||||
|
||||
既有 AgentDefinition revision 和 digest 仍是公开身份来源。Load 请求携带原身份,并使用已保存资源引用,不解析最新定义。Harness 替换后,Skill 通过持久内容引用保留。资源缺失或损坏会阻塞加载,不能静默丢弃技能。
|
||||
|
||||
MCP 和 Hook 副作用保留原稳定 operation ID 与 Runtime owner。结果未知时只观察原操作,不作为新工作重发。既有 MCP/Hook 的关闭和排空检查仍为必要条件。启用 MCP profile 仍要求部署显式配置 profile 白名单,并声明兼容 Hosted 生命周期能力;本次不全局启用,也不绕过升级门禁。
|
||||
|
||||
先部署兼容的 Harness 和 Runtime,再准入带新配置的 Session。对于非 MCP profile,没有扩展字段的已有 Session 保持可读。MCP profile 现在在 create 和 attach/load 校验时都要求非空 server pin。之前的存储准入可能保留了没有 pin 的 MCP profile,虽然 Hosted attachment 当时已经会拒绝它。不能假定这类行不存在:发布前应审计已保存的定义和 Session,单独保留历史访问能力,并在替换旧部署前通过旧部署结束或关闭受影响 Session。发布修正后的定义版本可用于新 Session,不会改写已有 Session pin。旧 Harness 可能拒绝新发布的配置,因此准入失败必须可见,不能被报告为安装成功。
|
||||
|
||||
## 边界
|
||||
|
||||
这是首条静态发布 Skill 路径。不实现 project/user/extension 文件系统发现、动态 catalog、slash 命令、路径触发激活、Skill `allowedTools`、Skill 注册 Hook、模型覆盖或参数文件展开。这些需要更完整的 C07 激活契约,目前明确拒绝而非静默忽略。AgentDefinition 显式 Hook catalog 独立于 Skill 激活。本次不增加 Environment 模板、包安装器、凭据 API、Runtime 共享或空闲回收策略。
|
||||
|
||||
## 涉及范围与验证
|
||||
|
||||
变更覆盖 AgentDefinition 校验和 OpenAPI schema、Java Hosted connector、不可变 Hosted 定义解析与加载,以及 Hosted Skill 工具。复用既有 MCP/Hook 实现。
|
||||
|
||||
实现前先在全局 CLI 确认基线,并区分公开准入缺口与普通 CLI 已有的 Skill 能力。测试合法及非法定义、资源发布、首次调用 Skill、未知 Skill 拒绝、资源损坏、冲突 load、Harness detach/load、MCP/Hook pin 转发以及 MCP 关闭能力门禁。使用确定性模型与 MCP fixture,证明模型看见并调用选定能力且不发现宿主 settings。运行定向 TypeScript 与 Java 测试、build/typecheck/bundle、适用的 Java 验证 profile,以及两轮完整 diff 自审。如实记录无法运行的物理环境,不能把 fixture 结果当作生产验收。
|
||||
|
||||
初始实现的本地 build、typecheck、bundle、lint 和格式检查通过;另通过 515 项 Hosted Session 测试、337 项 Skill/Hook/工具/恢复测试、50 项模型/定义测试、77 项 Java SDK 测试,以及 Java server 验证套件(2897 项,跳过七项)。选定的 Hosted Harness/MySQL 8.4 profile 两项测试均使用最终 bundle 通过。MySQL 全量 profile 首次通过 548/549,既有异步校验时序用例在隔离重跑中通过;这不能把原全量结果记为全绿。
|
||||
|
||||
最终打包进程 fixture 完成 18 次确定性模型请求,逐项核对实际调用名称、参数与持久历史。它覆盖按需加载、非法调用、不可变冷加载、资源损坏或缺失、普通混批拒绝,以及纯 Skill 的 SIGKILL 后 HTTP 恢复。混批 SIGKILL 的 load 仍明确阻塞并返回 HTTP 409;另外在同一真实日志上调用已构建的恢复 helper,验证同一拒绝与幂等性,不能声称 HTTP 已续行。该 fixture 没有执行 Runtime 工具。这些结果不代表物理 Linux 执行、真实 MCP/Hook 副作用或生产验收。
|
||||
|
||||
审查后的恢复修复 rebase 到 `55fbf99003` 后,build/typecheck/bundle 和改动文件 lint/格式检查均通过,同时通过 392 项 TypeScript 定向测试、31 项 Java 恢复定向测试、十项 Workspace 恢复/MySQL 测试、两项 Hosted Harness/MySQL 测试及 SpotBugs。完整 Java 测试阶段通过 2923 项、跳过七项,但 `mvn verify` 因该基线未改动的 CSI SQL worker 测试中十一处既有 Checkstyle 违规而失败;报错文件、规则与插件配置都与基线完全一致。独立 reader 探针验证了两处修复,重建后的 18 次请求进程 fixture 也保持上述恢复边界。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 新 Managed Session 能看到并调用已发布的静态 Skill,其正文进入持久模型历史。
|
||||
2. 即使发布了更新的 AgentDefinition revision,重新加载原 Session 仍保留原 Skill 字节与扩展 pin。
|
||||
3. 显式准入的 MCP 配置和 Hook catalog 到达既有 Runtime 实现,且不暴露 recipe 或凭据。
|
||||
4. 未支持的选项、缺失资源、profile 不兼容以及冲突 pin,在受影响的工作执行前明确失败。
|
||||
5. 回归检查覆盖已有 Session,以及其审批、恢复、关闭和排空行为。
|
||||
|
||||
## 开放问题
|
||||
|
||||
本受限切片没有待定问题。动态 Skill 激活和发现按既有配置设计另行推进。
|
||||
@@ -90,3 +90,30 @@ it('checks instruction bytes and UTF-8 before returning the pinned section', asy
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('accepts only bounded Skill resource references on create', () => {
|
||||
const skillsRef = {
|
||||
resourceId: 'skills',
|
||||
kind: 'managed-agent-skills',
|
||||
schemaVersion: 1,
|
||||
byteLength: 100,
|
||||
digest: 'c'.repeat(64),
|
||||
};
|
||||
expect(
|
||||
parseHostedAgentDefinition({ ...identity, skillsRef })?.skillsRef,
|
||||
).toEqual(skillsRef);
|
||||
for (const change of [
|
||||
{ kind: 'managed-agent-instructions' },
|
||||
{ schemaVersion: 2 },
|
||||
{ byteLength: 48 * 1024 + 1 },
|
||||
])
|
||||
expect(() =>
|
||||
parseHostedAgentDefinition({
|
||||
...identity,
|
||||
skillsRef: { ...skillsRef, ...change },
|
||||
}),
|
||||
).toThrow('Invalid agent skills reference.');
|
||||
expect(() =>
|
||||
parseHostedAgentDefinition({ ...identity, skillsRef }, false),
|
||||
).toThrow();
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ManagedSessionJsonValue,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import type { ManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-storage.js';
|
||||
import { HOSTED_SKILL_CATALOG_MAX_BYTES } from './hosted-skill-catalog.js';
|
||||
|
||||
export interface HostedAgentDefinition {
|
||||
agentId: string;
|
||||
@@ -18,6 +19,7 @@ export interface HostedAgentDefinition {
|
||||
digest: string;
|
||||
model?: { id: string };
|
||||
instructionsRef?: ManagedSessionDurableRef;
|
||||
skillsRef?: ManagedSessionDurableRef;
|
||||
}
|
||||
|
||||
export function parseHostedAgentDefinition(
|
||||
@@ -29,7 +31,7 @@ export function parseHostedAgentDefinition(
|
||||
throw new Error('Invalid agent definition.');
|
||||
const pin = value as Record<string, unknown>;
|
||||
const fields = create
|
||||
? ['agentId', 'revision', 'digest', 'model', 'instructionsRef']
|
||||
? ['agentId', 'revision', 'digest', 'model', 'instructionsRef', 'skillsRef']
|
||||
: ['agentId', 'revision', 'digest'];
|
||||
if (
|
||||
Object.keys(pin).some((key) => !fields.includes(key)) ||
|
||||
@@ -72,6 +74,19 @@ export function parseHostedAgentDefinition(
|
||||
throw new Error('Invalid agent instructions reference.');
|
||||
result.instructionsRef = ref;
|
||||
}
|
||||
if (pin['skillsRef'] !== undefined) {
|
||||
const ref = assertManagedSessionDurableRef(
|
||||
pin['skillsRef'] as ManagedSessionJsonValue,
|
||||
'agent skills',
|
||||
);
|
||||
if (
|
||||
ref.kind !== 'managed-agent-skills' ||
|
||||
ref.schemaVersion !== 1 ||
|
||||
ref.byteLength > HOSTED_SKILL_CATALOG_MAX_BYTES
|
||||
)
|
||||
throw new Error('Invalid agent skills reference.');
|
||||
result.skillsRef = ref;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
|
||||
import { HostedModelUnavailableError } from './hosted-agent-definition.js';
|
||||
import { HostedSkillCatalog } from './hosted-skill-catalog.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
|
||||
import type { HostedModelRequest } from './hosted-model-recovery.js';
|
||||
|
||||
@@ -1719,3 +1720,24 @@ it('keeps agent instructions ahead of refreshed project context', async () => {
|
||||
'# Agent instructions\n\nAGENT_SECTION\n\nPROJECT_SECTION',
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises only the published Skill catalog while keeping host Skill discovery disabled', async () => {
|
||||
const mocks = config([{ type: LlmEventType.Finished }]);
|
||||
await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
skills: new HostedSkillCatalog([
|
||||
{ name: 'published', description: 'PUBLISHED_DESCRIPTION', body: 'BODY' },
|
||||
]),
|
||||
workspaceContext: { read: () => 'PROJECT_SECTION' },
|
||||
});
|
||||
expect(mocks.initialize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skipSkillManager: true }),
|
||||
);
|
||||
expect(mocks.setUserMemory).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('PUBLISHED_DESCRIPTION'),
|
||||
);
|
||||
expect(mocks.setUserMemory.mock.calls.at(-1)?.[0]).toContain(
|
||||
'PROJECT_SECTION',
|
||||
);
|
||||
expect(mocks.refreshSystemInstruction).toHaveBeenCalled();
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from './hosted-agent-definition.js';
|
||||
|
||||
import type { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js';
|
||||
import type { HostedSkillCatalog } from './hosted-skill-catalog.js';
|
||||
import {
|
||||
HostedHookRecoveryRequiredError,
|
||||
type HostedHookSession,
|
||||
@@ -149,6 +150,7 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
workspaceContext?: { read(): string | undefined };
|
||||
agentDefinition?: HostedAgentDefinition;
|
||||
agentInstructions?: string;
|
||||
skills?: HostedSkillCatalog;
|
||||
textDeltas?: HostedHarnessTextDeltas;
|
||||
}): Promise<HostedHarnessModelResult> {
|
||||
const settings = loadSettings(input.cwd, {
|
||||
@@ -179,6 +181,7 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
input.agentInstructions
|
||||
? `# Agent instructions\n\n${input.agentInstructions}`
|
||||
: undefined,
|
||||
input.skills?.instructions(),
|
||||
context,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -290,7 +293,7 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
await input.hooks?.ensureReady(input.signal);
|
||||
// Seed instructions when memory reload is skipped; restore them after
|
||||
// initialize() when its hierarchical memory refresh replaces user memory.
|
||||
if (input.agentInstructions)
|
||||
if (input.agentInstructions || input.skills)
|
||||
config.setUserMemory(
|
||||
contextWithInstructions(input.workspaceContext?.read()),
|
||||
);
|
||||
@@ -306,7 +309,7 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
const authType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!authType)
|
||||
throw new Error('Hosted Harness model authentication is unavailable.');
|
||||
if (input.agentInstructions) {
|
||||
if (input.agentInstructions || input.skills) {
|
||||
config.setUserMemory(
|
||||
contextWithInstructions(input.workspaceContext?.read()),
|
||||
);
|
||||
@@ -317,7 +320,7 @@ export async function runHostedHarnessTextTurn(input: {
|
||||
if (model) throw new HostedModelUnavailableError(cause);
|
||||
throw cause;
|
||||
}
|
||||
if (input.agentInstructions)
|
||||
if (input.agentInstructions || input.skills)
|
||||
await config.getLlmClient().refreshSystemInstruction();
|
||||
await input.modelScope?.bindBudget(config.getTurnBudget(), input.prompt);
|
||||
runPromptHook = input.modelScope
|
||||
|
||||
@@ -30868,4 +30868,221 @@ describe('Hosted Harness stored definitions', () => {
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads published Skills from the saved resource after detach and rejects unbound Skill definitions', async () => {
|
||||
const server = await app(true);
|
||||
const resources = LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir: state.root,
|
||||
sessionKey: {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId: SESSION_ID,
|
||||
},
|
||||
});
|
||||
const skillsRef = await resources.publish(
|
||||
'managed-agent-skills',
|
||||
Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'published',
|
||||
description: 'Published instructions',
|
||||
body: 'ORIGINAL_SKILL_BODY',
|
||||
base_directory: 'docs',
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const identity = {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
};
|
||||
const definition = { ...identity, skillsRef };
|
||||
const request = {
|
||||
sessionId: SESSION_ID,
|
||||
sessionScope: 'thread',
|
||||
managedSessionStore: store(),
|
||||
agentDefinition: definition,
|
||||
};
|
||||
await headers(supertest(server).post('/session')).send(request).expect(400);
|
||||
const created = await headers(supertest(server).post('/session'))
|
||||
.send({ ...request, toolProfile: 'hosted-workspace-files/1' })
|
||||
.expect(200);
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`))
|
||||
.set('X-Qwen-Client-Id', created.body.clientId)
|
||||
.expect(204);
|
||||
const replacement = await app(true);
|
||||
const loaded = await headers(
|
||||
supertest(replacement).post(`/session/${SESSION_ID}/load`),
|
||||
)
|
||||
.send({ managedSessionStore: store(), agentDefinition: identity })
|
||||
.expect(200);
|
||||
state.model.mockImplementationOnce(async (input) => {
|
||||
const call = {
|
||||
name: 'skill',
|
||||
callId: 'call-skill',
|
||||
args: { skill: 'published' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: PROMPT_ID,
|
||||
};
|
||||
expect(await input.toolTurn!.declarations(input.signal)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'skill' })]),
|
||||
);
|
||||
const result = await input.toolTurn!.execute(
|
||||
[call],
|
||||
[
|
||||
{
|
||||
functionCall: { name: call.name, id: call.callId, args: call.args },
|
||||
},
|
||||
],
|
||||
'test-model',
|
||||
input.signal,
|
||||
);
|
||||
expect(JSON.stringify(result)).toContain('ORIGINAL_SKILL_BODY');
|
||||
expect(JSON.stringify(result)).toContain('docs');
|
||||
return { text: 'Skill loaded', model: 'test-model' };
|
||||
});
|
||||
const prompt = [{ type: 'text', text: 'Use the published skill' }];
|
||||
await headers(supertest(replacement).post(`/session/${SESSION_ID}/prompt`))
|
||||
.set('X-Qwen-Client-Id', loaded.body.clientId)
|
||||
.send({
|
||||
prompt,
|
||||
promptId: PROMPT_ID,
|
||||
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
|
||||
})
|
||||
.expect(202);
|
||||
await vi.waitFor(async () => {
|
||||
const status = await headers(
|
||||
supertest(replacement).get(`/session/${SESSION_ID}/status`),
|
||||
).set('X-Qwen-Client-Id', loaded.body.clientId);
|
||||
expect(status.body.hasActivePrompt).toBe(false);
|
||||
expect(status.body.recoveryBlocked).toBe(false);
|
||||
});
|
||||
|
||||
expect(state.model).toHaveBeenCalledOnce();
|
||||
await expect(state.model.mock.results[0].value).resolves.toMatchObject({
|
||||
text: 'Skill loaded',
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers a pure Skill call whose assistant committed before its response', async () => {
|
||||
const server = await app(true);
|
||||
const resources = LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir: state.root,
|
||||
sessionKey: {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId: SESSION_ID,
|
||||
},
|
||||
});
|
||||
const skillsRef = await resources.publish(
|
||||
'managed-agent-skills',
|
||||
Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'published',
|
||||
description: 'Instructions',
|
||||
body: 'RECOVERED_ORIGINAL_SKILL',
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const identity = {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
};
|
||||
const created = await headers(supertest(server).post('/session'))
|
||||
.send({
|
||||
sessionId: SESSION_ID,
|
||||
sessionScope: 'thread',
|
||||
managedSessionStore: store(),
|
||||
agentDefinition: { ...identity, skillsRef },
|
||||
toolProfile: 'hosted-workspace-files/1',
|
||||
})
|
||||
.expect(200);
|
||||
const originalWrite = ManagedSessionRecordSink.prototype.write;
|
||||
const write = vi
|
||||
.spyOn(ManagedSessionRecordSink.prototype, 'write')
|
||||
.mockImplementation(async function (
|
||||
this: ManagedSessionRecordSink,
|
||||
record,
|
||||
) {
|
||||
if (
|
||||
record.type === 'tool_result' &&
|
||||
record.message?.parts?.some(
|
||||
(part) => part.functionResponse?.name === 'skill',
|
||||
)
|
||||
)
|
||||
throw new Error('Simulated Skill response commit failure');
|
||||
return originalWrite.call(this, record);
|
||||
});
|
||||
state.model.mockImplementationOnce(async (input) => {
|
||||
const call = {
|
||||
name: 'skill',
|
||||
callId: 'call-recover-skill',
|
||||
args: { skill: 'published' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: PROMPT_ID,
|
||||
};
|
||||
await input.toolTurn!.execute(
|
||||
[call],
|
||||
[
|
||||
{
|
||||
functionCall: { name: call.name, id: call.callId, args: call.args },
|
||||
},
|
||||
],
|
||||
'test-model',
|
||||
input.signal,
|
||||
);
|
||||
return { text: 'unreached', model: 'test-model' };
|
||||
});
|
||||
const prompt = [{ type: 'text', text: 'Load instructions' }];
|
||||
await headers(supertest(server).post(`/session/${SESSION_ID}/prompt`))
|
||||
.set('X-Qwen-Client-Id', created.body.clientId)
|
||||
.send({
|
||||
prompt,
|
||||
promptId: PROMPT_ID,
|
||||
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
|
||||
})
|
||||
.expect(202);
|
||||
await vi.waitFor(async () => {
|
||||
const status = await headers(
|
||||
supertest(server).get(`/session/${SESSION_ID}/status`),
|
||||
).set('X-Qwen-Client-Id', created.body.clientId);
|
||||
expect(status.body.hasActivePrompt).toBe(false);
|
||||
expect(status.body.recoveryBlocked).toBe(true);
|
||||
});
|
||||
write.mockRestore();
|
||||
await headers(supertest(server).post(`/session/${SESSION_ID}/detach`)).set(
|
||||
'X-Qwen-Client-Id',
|
||||
created.body.clientId,
|
||||
);
|
||||
let recovered: readonly unknown[] | undefined;
|
||||
state.model.mockImplementationOnce(async (input) => {
|
||||
recovered = input.resumeFromToolResults;
|
||||
return { text: 'continued', model: 'test-model' };
|
||||
});
|
||||
const replacement = await app(true);
|
||||
const loaded = await headers(
|
||||
supertest(replacement).post(`/session/${SESSION_ID}/load`),
|
||||
)
|
||||
.send({
|
||||
managedSessionStore: store(),
|
||||
agentDefinition: identity,
|
||||
driveRuntimeRecovery: true,
|
||||
})
|
||||
.expect(200);
|
||||
await vi.waitFor(() =>
|
||||
expect(JSON.stringify(recovered)).toContain('RECOVERED_ORIGINAL_SKILL'),
|
||||
);
|
||||
expect(JSON.stringify(recovered)).not.toContain('never ran');
|
||||
await vi.waitFor(async () => {
|
||||
const status = await headers(
|
||||
supertest(replacement).get(`/session/${SESSION_ID}/status`),
|
||||
).set('X-Qwen-Client-Id', loaded.body.clientId);
|
||||
expect(status.body.hasActivePrompt).toBe(false);
|
||||
expect(status.body.recoveryBlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,10 @@ import {
|
||||
type HostedAgentDefinition,
|
||||
} from './hosted-agent-definition.js';
|
||||
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
|
||||
import {
|
||||
HostedSkillCatalog,
|
||||
readHostedSkills,
|
||||
} from './hosted-skill-catalog.js';
|
||||
import {
|
||||
parseHostedWorkflowLaunchBlock,
|
||||
runHostedWorkflowTurn,
|
||||
@@ -340,6 +344,7 @@ interface HostedSession {
|
||||
storeBaseUrl: string;
|
||||
definition: Record<string, unknown> | null;
|
||||
agentDefinition?: HostedAgentDefinition;
|
||||
skills?: HostedSkillCatalog;
|
||||
clientId: string;
|
||||
cwd: string;
|
||||
streams: Set<() => void>;
|
||||
@@ -2118,6 +2123,10 @@ async function verifyWorkspaceRestore(
|
||||
await readRef(pin.instructionsRef);
|
||||
await readHostedAgentInstructions(pin, resources);
|
||||
}
|
||||
if (pin?.skillsRef) {
|
||||
await readRef(pin.skillsRef);
|
||||
await readHostedSkills(pin.skillsRef, resources);
|
||||
}
|
||||
}
|
||||
if (ref.kind === 'managed-tool-outcome') {
|
||||
const outcome = object(JSON.parse(bytes.toString('utf8')));
|
||||
@@ -3074,6 +3083,7 @@ async function executeHostedTurn(
|
||||
{
|
||||
mcp: session.mcp,
|
||||
hooks: session.hooks,
|
||||
skills: session.skills,
|
||||
profile: session.toolProfile,
|
||||
context: workspaceContext,
|
||||
childRuns: session.childRuns,
|
||||
@@ -3145,6 +3155,7 @@ async function executeHostedTurn(
|
||||
session.agentDefinition,
|
||||
session.managed.resources,
|
||||
),
|
||||
skills: session.skills,
|
||||
...(session.hooks ? { hooks: session.hooks } : {}),
|
||||
...(toolTurn ? { toolTurn } : {}),
|
||||
...(resumeFromToolResults ? { resumeFromToolResults } : {}),
|
||||
@@ -3406,10 +3417,12 @@ export function registerHostedHarnessSessionRoutes(
|
||||
let toolProfile = body?.['toolProfile'];
|
||||
let captureBytes = body?.['captureBytes'];
|
||||
if (
|
||||
toolProfile !== undefined &&
|
||||
((!isHostedWorkspaceProfile(toolProfile) &&
|
||||
toolProfile !== HOSTED_MCP_PROFILE) ||
|
||||
!brokerOptions)
|
||||
(agentDefinition?.skillsRef !== undefined &&
|
||||
(!toolProfile || !brokerOptions)) ||
|
||||
(toolProfile !== undefined &&
|
||||
((!isHostedWorkspaceProfile(toolProfile) &&
|
||||
toolProfile !== HOSTED_MCP_PROFILE) ||
|
||||
!brokerOptions))
|
||||
) {
|
||||
error(res, 400, 'hosted_tool_profile_unavailable');
|
||||
return;
|
||||
@@ -4205,6 +4218,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
),
|
||||
);
|
||||
let savedAgentDefinition: HostedAgentDefinition | undefined;
|
||||
let skills: HostedSkillCatalog | undefined;
|
||||
try {
|
||||
savedAgentDefinition = parseHostedAgentDefinition(
|
||||
definition?.['agentDefinition'],
|
||||
@@ -4215,6 +4229,13 @@ export function registerHostedHarnessSessionRoutes(
|
||||
savedAgentDefinition,
|
||||
managed.resources,
|
||||
);
|
||||
if (savedAgentDefinition?.skillsRef)
|
||||
skills = new HostedSkillCatalog(
|
||||
await readHostedSkills(
|
||||
savedAgentDefinition.skillsRef,
|
||||
managed.resources,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
await managed.close();
|
||||
error(res, 409, 'hosted_agent_definition_conflict');
|
||||
@@ -4252,7 +4273,8 @@ export function registerHostedHarnessSessionRoutes(
|
||||
captureBytes = definition?.['captureBytes'];
|
||||
const workspaceProfile = isHostedWorkspaceProfile(toolProfile);
|
||||
if (
|
||||
(hookCatalog !== undefined && (!toolProfile || !brokerOptions)) ||
|
||||
((hookCatalog !== undefined || skills !== undefined) &&
|
||||
(!toolProfile || !brokerOptions)) ||
|
||||
(toolProfile !== undefined &&
|
||||
((!isHostedWorkspaceProfile(toolProfile) &&
|
||||
toolProfile !== HOSTED_MCP_PROFILE) ||
|
||||
@@ -4279,6 +4301,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
storeBaseUrl: store.baseUrl,
|
||||
definition,
|
||||
agentDefinition: savedAgentDefinition,
|
||||
...(skills ? { skills } : {}),
|
||||
clientId: randomUUID(),
|
||||
cwd,
|
||||
streams: new Set(),
|
||||
@@ -4921,6 +4944,8 @@ export function registerHostedHarnessSessionRoutes(
|
||||
sessionId,
|
||||
cwd,
|
||||
promptId: unsettled,
|
||||
skills: session.skills,
|
||||
hooks: session.hooks,
|
||||
children: session.childAgents,
|
||||
teams: session.teams,
|
||||
messages: session.messages,
|
||||
@@ -5158,6 +5183,59 @@ export function registerHostedHarnessSessionRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
takeover &&
|
||||
unsettled &&
|
||||
session.skills &&
|
||||
!resume &&
|
||||
!resumeApproval &&
|
||||
!committedModelOutput &&
|
||||
body?.['cancellationTakeover'] !== true &&
|
||||
(await parkNeedsNoRuntimeSettlement(managed))
|
||||
) {
|
||||
const current = (await managed.sink.project()).filter(
|
||||
(record) => record.daemonPromptId === unsettled,
|
||||
);
|
||||
const assistant = current.findLast(
|
||||
(record) => record.type === 'assistant',
|
||||
);
|
||||
const calls = assistant?.message?.parts?.flatMap((part) =>
|
||||
part.functionCall ? [part.functionCall] : [],
|
||||
);
|
||||
if (calls?.length && calls.every((call) => call.name === 'skill')) {
|
||||
await answerResumedTurnCalls({
|
||||
session: managed,
|
||||
sessionId,
|
||||
cwd,
|
||||
promptId: unsettled,
|
||||
skills: session.skills,
|
||||
hooks: session.hooks,
|
||||
});
|
||||
const projected = (await managed.sink.project()).filter(
|
||||
(record) => record.daemonPromptId === unsettled,
|
||||
);
|
||||
const user = projected.find((record) => record.type === 'user');
|
||||
const text = user?.message?.parts
|
||||
?.flatMap((part) =>
|
||||
typeof part.text === 'string' ? [part.text] : [],
|
||||
)
|
||||
.join('\n');
|
||||
if (!text)
|
||||
throw new HostedToolRecoveryRequiredError(
|
||||
'Original Skill Prompt is missing.',
|
||||
);
|
||||
const lastAssistant = projected.findLastIndex(
|
||||
(record) => record.type === 'assistant',
|
||||
);
|
||||
resume = {
|
||||
promptId: unsettled,
|
||||
text,
|
||||
parts: projected
|
||||
.slice(lastAssistant + 1)
|
||||
.flatMap((record) => record.message?.parts ?? []),
|
||||
};
|
||||
}
|
||||
}
|
||||
let settlePromptId: string | undefined = committedModelOutput?.promptId;
|
||||
let recovery: HostedRuntimeRecoveryReport | undefined;
|
||||
// A takeover that answers inapplicable has spoken: the plain attach
|
||||
@@ -5169,6 +5247,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
takeover &&
|
||||
!resumeApproval &&
|
||||
!resumeModel &&
|
||||
!resume &&
|
||||
!settlePromptId
|
||||
) {
|
||||
// A parked Runtime turn is taken over, not refused: settle its
|
||||
@@ -5387,6 +5466,8 @@ export function registerHostedHarnessSessionRoutes(
|
||||
sessionId,
|
||||
cwd,
|
||||
promptId,
|
||||
skills: session.skills,
|
||||
hooks: session.hooks,
|
||||
children: session.childAgents,
|
||||
teams: session.teams,
|
||||
messages: session.messages,
|
||||
@@ -8070,6 +8151,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
},
|
||||
{
|
||||
mcp: session.mcp,
|
||||
skills: session.skills,
|
||||
profile: session.toolProfile,
|
||||
context: workspaceContext,
|
||||
childRuns: session.childRuns,
|
||||
@@ -8142,6 +8224,8 @@ export function registerHostedHarnessSessionRoutes(
|
||||
gapText: HOSTED_AGENT_CALL_NOT_REACHED_TEXT,
|
||||
children: session.childAgents,
|
||||
messages: session.messages,
|
||||
skills: session.skills,
|
||||
hooks: session.hooks,
|
||||
signal: abort.signal,
|
||||
consume: (childRunId) => session.childConsumption.add(childRunId),
|
||||
});
|
||||
@@ -8214,6 +8298,8 @@ export function registerHostedHarnessSessionRoutes(
|
||||
sessionId,
|
||||
cwd: session.cwd,
|
||||
promptId,
|
||||
skills: session.skills,
|
||||
hooks: session.hooks,
|
||||
children: session.childAgents,
|
||||
teams: session.teams,
|
||||
messages: session.messages,
|
||||
@@ -8294,6 +8380,7 @@ export function registerHostedHarnessSessionRoutes(
|
||||
session.agentDefinition,
|
||||
session.managed.resources,
|
||||
),
|
||||
skills: session.skills,
|
||||
toolTurn,
|
||||
resumeFromToolResults: resumeParts,
|
||||
textDeltas: deltas,
|
||||
|
||||
@@ -143,7 +143,7 @@ function semanticInput(value: object): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(fields)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function semanticInputDigest(value: object): string {
|
||||
export function semanticInputDigest(value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(semanticInput(value), (_key, item: unknown) =>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import { ManagedSessionConflictError } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-authority.js';
|
||||
@@ -38,6 +39,23 @@ import {
|
||||
type HostedChildAgentSession,
|
||||
} from './hosted-child-agent-session.js';
|
||||
import type { HostedSessionMessageSession } from './hosted-session-message-session.js';
|
||||
import {
|
||||
HOSTED_SKILL_APPROVAL_REFUSAL,
|
||||
HOSTED_SKILL_BATCH_REFUSAL,
|
||||
type HostedSkillCatalog,
|
||||
} from './hosted-skill-catalog.js';
|
||||
import {
|
||||
hostedHookOccurrenceId,
|
||||
HostedHookRecoveryRequiredError,
|
||||
semanticInputDigest,
|
||||
type HostedHookSession,
|
||||
} from './hosted-hook-session.js';
|
||||
import { parseHookExecution } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-record.js';
|
||||
import {
|
||||
HookEventName,
|
||||
PreToolUseHookOutput,
|
||||
type HookOutput,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import {
|
||||
sessionCommandEvidenceText,
|
||||
type HostedSessionCommandSession,
|
||||
@@ -74,6 +92,7 @@ import {
|
||||
import {
|
||||
endHostedAction,
|
||||
readHostedActionOptions,
|
||||
readHostedApprovalDefinition,
|
||||
} from './hosted-tool-approval.js';
|
||||
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
|
||||
import {
|
||||
@@ -557,6 +576,8 @@ async function answerAbandonedTurnCalls(input: {
|
||||
* never a cancellation nobody witnessed. */
|
||||
unobserved?: ReadonlySet<string>;
|
||||
commands?: HostedSessionCommandSession;
|
||||
skills?: HostedSkillCatalog;
|
||||
hooks?: HostedHookSession;
|
||||
/** A journal projection the caller already holds: one snapshot serves
|
||||
* the caller's classification and this answering pass instead of two
|
||||
* full walks of the committed events (the settle's projection walk). */
|
||||
@@ -573,13 +594,26 @@ async function answerAbandonedTurnCalls(input: {
|
||||
input.promptId,
|
||||
records,
|
||||
);
|
||||
const owed = new Map<string, { name: string; messageId: string }>();
|
||||
for (const record of records.filter((entry) => entry.type === 'assistant'))
|
||||
for (const part of record.message?.parts ?? []) {
|
||||
const owed = new Map<
|
||||
string,
|
||||
{ name: string; messageId: string; args: unknown; mixedSkillBatch: boolean }
|
||||
>();
|
||||
for (const record of records.filter((entry) => entry.type === 'assistant')) {
|
||||
const parts = record.message?.parts ?? [];
|
||||
const mixedSkillBatch = parts.some(
|
||||
(part) => part.functionCall && part.functionCall.name !== 'skill',
|
||||
);
|
||||
for (const part of parts) {
|
||||
const call = part.functionCall;
|
||||
if (call?.id && call.name && !answered.has(call.id) && !owed.has(call.id))
|
||||
owed.set(call.id, { name: call.name, messageId: record.uuid });
|
||||
owed.set(call.id, {
|
||||
name: call.name,
|
||||
messageId: record.uuid,
|
||||
args: call.args ?? {},
|
||||
mixedSkillBatch,
|
||||
});
|
||||
}
|
||||
}
|
||||
let answeredNow = 0;
|
||||
for (const [functionCallId, call] of owed) {
|
||||
const callKey = hostedChildRunIdFor(input.promptId, functionCallId);
|
||||
@@ -612,7 +646,17 @@ async function answerAbandonedTurnCalls(input: {
|
||||
callId: functionCallId,
|
||||
});
|
||||
let parts: Part[];
|
||||
if (planDecision !== undefined) {
|
||||
if (uncommitted === 'hosted' && call.name === 'skill' && input.skills) {
|
||||
parts = await hostedSkillResponse(
|
||||
input.session,
|
||||
input.skills,
|
||||
input.promptId,
|
||||
functionCallId,
|
||||
call.args,
|
||||
call.mixedSkillBatch,
|
||||
input.hooks,
|
||||
);
|
||||
} else if (planDecision !== undefined) {
|
||||
parts = convertToFunctionResponse(call.name, functionCallId, [
|
||||
{ text: planDecision },
|
||||
]);
|
||||
@@ -827,6 +871,113 @@ function sentMessageText(
|
||||
/** The interruption every settle or resume route below names. */
|
||||
const INTERRUPTED_HARNESS = 'the Harness that asked was interrupted';
|
||||
|
||||
async function hostedSkillResponse(
|
||||
session: ManagedSession,
|
||||
catalog: HostedSkillCatalog,
|
||||
promptId: string,
|
||||
callId: string,
|
||||
args: unknown,
|
||||
mixedSkillBatch: boolean,
|
||||
hooks?: HostedHookSession,
|
||||
): Promise<Part[]> {
|
||||
const refused = (reason: string) =>
|
||||
convertToFunctionErrorResponse('skill', callId, [], reason);
|
||||
if (mixedSkillBatch) return refused(HOSTED_SKILL_BATCH_REFUSAL);
|
||||
const initialProblem = catalog.validationError(args);
|
||||
if (initialProblem) return refused(initialProblem);
|
||||
if (hooks) {
|
||||
const marker = session.authority.extensionRecord(
|
||||
'hook_execution',
|
||||
hostedHookOccurrenceId(HookEventName.PreToolUse, `${promptId}:${callId}`),
|
||||
);
|
||||
if (!marker)
|
||||
return refused(
|
||||
'This Skill call did not run: no PreToolUse Hook decision was recorded before the Harness was interrupted.',
|
||||
);
|
||||
const record = parseHookExecution(marker.record);
|
||||
if (record.run.execution === 'not_started_proven')
|
||||
return refused('The turn was cancelled before this tool call ran.');
|
||||
if (!record.resultRef) throw new HostedHookRecoveryRequiredError();
|
||||
if (
|
||||
record.hookId !== '__plan__' ||
|
||||
record.eventName !== HookEventName.PreToolUse
|
||||
)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
const plan = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
await session.resources.read(record.planRef),
|
||||
),
|
||||
) as { refusedInputDigest?: unknown };
|
||||
if (plan.refusedInputDigest !== undefined) {
|
||||
const definition = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
await session.resources.read(
|
||||
session.authority.sessionHeader.definitionRef,
|
||||
),
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
const approval = readHostedApprovalDefinition(definition);
|
||||
if (
|
||||
!approval ||
|
||||
plan.refusedInputDigest !==
|
||||
semanticInputDigest({
|
||||
tool_name: 'skill',
|
||||
tool_input: args,
|
||||
tool_use_id: callId,
|
||||
permission_mode: approval.mode,
|
||||
prompt_id: promptId,
|
||||
})
|
||||
)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
} else {
|
||||
const original = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
await session.resources.read(record.inputRef),
|
||||
),
|
||||
) as {
|
||||
tool_name?: string;
|
||||
tool_use_id?: string;
|
||||
prompt_id?: string;
|
||||
tool_input?: unknown;
|
||||
};
|
||||
if (
|
||||
original.tool_name !== 'skill' ||
|
||||
original.tool_use_id !== callId ||
|
||||
original.prompt_id !== promptId ||
|
||||
!isDeepStrictEqual(original.tool_input, args)
|
||||
)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
}
|
||||
const saved = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
await session.resources.read(record.resultRef),
|
||||
),
|
||||
) as { output?: HookOutput };
|
||||
const output = saved.output && new PreToolUseHookOutput(saved.output);
|
||||
const permission = output?.getPermissionDecision();
|
||||
if (
|
||||
output?.shouldStopExecution() ||
|
||||
output?.isBlockingDecision() ||
|
||||
permission === 'deny'
|
||||
)
|
||||
return refused(
|
||||
(permission === 'deny' && output?.getPermissionDecisionReason()) ||
|
||||
output?.getEffectiveReason() ||
|
||||
'PreToolUse Hook denied the call.',
|
||||
);
|
||||
if (permission === 'ask') return refused(HOSTED_SKILL_APPROVAL_REFUSAL);
|
||||
const updated = saved.output?.hookSpecificOutput?.['updatedInput'];
|
||||
if (updated && typeof updated === 'object' && !Array.isArray(updated))
|
||||
args = updated;
|
||||
}
|
||||
const problem = catalog.validationError(args);
|
||||
return problem
|
||||
? convertToFunctionErrorResponse('skill', callId, [], problem)
|
||||
: convertToFunctionResponse('skill', callId, [
|
||||
{ text: catalog.response(args) },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers, by what committed, the calls an interrupted Turn committed but
|
||||
* never answered — a team call's writes, a background launch's admission —
|
||||
@@ -871,6 +1022,8 @@ export function answerResumedTurnCalls(input: {
|
||||
teams?: HostedTeamSession;
|
||||
messages?: HostedSessionMessageSession;
|
||||
commands?: HostedSessionCommandSession;
|
||||
skills?: HostedSkillCatalog;
|
||||
hooks?: HostedHookSession;
|
||||
}): Promise<number> {
|
||||
return answerAbandonedTurnCalls({
|
||||
...input,
|
||||
@@ -949,6 +1102,8 @@ export async function fillParkedRoundAgentGaps(input: {
|
||||
/** H4d-b: the message funnel, so a committed send_message keeps the
|
||||
* answer it earned instead of the never-admitted one. */
|
||||
messages?: HostedSessionMessageSession;
|
||||
skills?: HostedSkillCatalog;
|
||||
hooks?: HostedHookSession;
|
||||
signal?: AbortSignal;
|
||||
consume?: (childRunId: string) => void;
|
||||
}): Promise<number> {
|
||||
@@ -1026,6 +1181,9 @@ export async function fillParkedRoundAgentGaps(input: {
|
||||
const fits = (parts: Part[]): boolean =>
|
||||
templateRecord(parts).byteLength <=
|
||||
HTTP_MANAGED_SESSION_STORE_CONTRACT.maxInlineResourceBytes;
|
||||
const mixedSkillBatch = (assistant.message?.parts ?? []).some(
|
||||
(part) => part.functionCall && part.functionCall.name !== 'skill',
|
||||
);
|
||||
let filled = 0;
|
||||
for (const part of assistant.message?.parts ?? []) {
|
||||
const callId = part.functionCall?.id;
|
||||
@@ -1035,6 +1193,23 @@ export async function fillParkedRoundAgentGaps(input: {
|
||||
// The journaled set gates only the fold, never the replay-safe marks —
|
||||
// both sibling arms in this diff document and follow the same rule.
|
||||
const foldOwed = !journaled.has(callId);
|
||||
if (name === 'skill' && input.skills) {
|
||||
if (foldOwed) {
|
||||
await writeFold(
|
||||
await hostedSkillResponse(
|
||||
input.managed,
|
||||
input.skills,
|
||||
input.promptId,
|
||||
callId,
|
||||
part.functionCall?.args ?? {},
|
||||
mixedSkillBatch,
|
||||
input.hooks,
|
||||
),
|
||||
);
|
||||
filled += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const children = input.children;
|
||||
const sent =
|
||||
name === 'send_message'
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import {
|
||||
HostedSkillCatalog,
|
||||
parseHostedSkills,
|
||||
readHostedSkills,
|
||||
} from './hosted-skill-catalog.js';
|
||||
|
||||
const skill = {
|
||||
name: 'code-review',
|
||||
description: 'Review <code> changes.',
|
||||
body: 'Read references/review.md before reviewing.',
|
||||
base_directory: '.qwen/skills/code-review',
|
||||
};
|
||||
|
||||
it('advertises only published descriptions and loads a fixed Workspace-relative body', () => {
|
||||
const source = [{ ...skill }];
|
||||
const catalog = new HostedSkillCatalog(source);
|
||||
source[0].body = 'Changed after publication.';
|
||||
expect(catalog.instructions()).toContain('Review <code> changes.');
|
||||
expect(catalog.instructions()).not.toContain(skill.body);
|
||||
expect(catalog.declaration()).toMatchObject({
|
||||
name: 'skill',
|
||||
parametersJsonSchema: {
|
||||
required: ['skill'],
|
||||
additionalProperties: false,
|
||||
properties: { skill: { enum: ['code-review'] } },
|
||||
},
|
||||
});
|
||||
const response = catalog.response({ skill: 'code-review' });
|
||||
expect(response).toContain(skill.base_directory);
|
||||
expect(response).toContain(skill.body);
|
||||
expect(catalog.response({ skill: 'code-review' })).toBe(response);
|
||||
expect(catalog.response({ skill: 'code-review' })).not.toContain(
|
||||
source[0].body,
|
||||
);
|
||||
expect(new HostedSkillCatalog([]).declaration()).toBeUndefined();
|
||||
expect(new HostedSkillCatalog([]).instructions()).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...skill, hooks: {} },
|
||||
{ ...skill, allowedTools: ['Bash'] },
|
||||
{ ...skill, model: 'fast' },
|
||||
{ ...skill, paths: ['src/**'] },
|
||||
{ ...skill, name: '<injected>' },
|
||||
{ ...skill, name: 'a'.repeat(129) },
|
||||
{ ...skill, description: '界'.repeat(1366) },
|
||||
{ ...skill, body: '界'.repeat(10923) },
|
||||
{ ...skill, body: '' },
|
||||
{ ...skill, body: '\ud800' },
|
||||
{ ...skill, description: '\udfff' },
|
||||
{ ...skill, base_directory: '/tmp/private' },
|
||||
{ ...skill, base_directory: '../private' },
|
||||
{ ...skill, base_directory: 'a/../b' },
|
||||
{ ...skill, base_directory: 'a//b' },
|
||||
{ ...skill, base_directory: './a' },
|
||||
{ ...skill, base_directory: 'a/.' },
|
||||
{ ...skill, base_directory: 'a\\b' },
|
||||
{ ...skill, base_directory: 'C:private' },
|
||||
{ ...skill, base_directory: 'a\nb' },
|
||||
{ ...skill, base_directory: 'a:b' },
|
||||
{ ...skill, base_directory: 'a\u0085b' },
|
||||
])('refuses unsupported or invalid published fields: %j', (entry) => {
|
||||
expect(() => parseHostedSkills([entry])).toThrow();
|
||||
});
|
||||
|
||||
it('refuses duplicate names, item and total byte limits, and unsupported invocation options', () => {
|
||||
expect(() => parseHostedSkills([skill, skill])).toThrow();
|
||||
expect(() =>
|
||||
parseHostedSkills(
|
||||
Array.from({ length: 17 }, (_, i) => ({ ...skill, name: `s${i}` })),
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseHostedSkills([
|
||||
{ ...skill, name: 'a', body: 'x'.repeat(32768) },
|
||||
{ ...skill, name: 'b', body: 'x'.repeat(32768) },
|
||||
]),
|
||||
).toThrow();
|
||||
const catalog = new HostedSkillCatalog([skill]);
|
||||
for (const args of [
|
||||
{},
|
||||
[],
|
||||
{ skill: 'unknown' },
|
||||
{ skill: 'code-review', args: '--post' },
|
||||
{ skill: 'code-review', model: 'fast' },
|
||||
]) {
|
||||
expect(catalog.validationError(args)).toBeTypeOf('string');
|
||||
expect(() => catalog.response(args)).toThrow();
|
||||
}
|
||||
expect(
|
||||
new HostedSkillCatalog([{ ...skill, base_directory: undefined }]).response({
|
||||
skill: skill.name,
|
||||
}),
|
||||
).toContain('Session working directory: .\n');
|
||||
});
|
||||
|
||||
it('verifies the original durable bytes, kind, digest and UTF-8 before loading', async () => {
|
||||
const bytes = Buffer.from(JSON.stringify([skill]));
|
||||
const ref = {
|
||||
resourceId: 'published-skills',
|
||||
kind: 'managed-agent-skills',
|
||||
schemaVersion: 1,
|
||||
byteLength: bytes.length,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
const read = vi.fn(async () => bytes);
|
||||
expect(await readHostedSkills(ref, { read })).toEqual([skill]);
|
||||
await expect(
|
||||
readHostedSkills({ ...ref, kind: 'managed-agent-instructions' }, { read }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readHostedSkills({ ...ref, byteLength: ref.byteLength + 1 }, { read }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readHostedSkills({ ...ref, digest: '0'.repeat(64) }, { read }),
|
||||
).rejects.toThrow();
|
||||
const invalid = Buffer.from([0xff]);
|
||||
await expect(
|
||||
readHostedSkills(
|
||||
{
|
||||
...ref,
|
||||
byteLength: invalid.length,
|
||||
digest: createHash('sha256').update(invalid).digest('hex'),
|
||||
},
|
||||
{ read: async () => invalid },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(await readHostedSkills(undefined, { read })).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { FunctionDeclaration } from '@google/genai';
|
||||
import { validateSkillName } from '@qwen-code/qwen-code-core/skills/types.js';
|
||||
import { renderAvailableSkillsBlock } from '@qwen-code/qwen-code-core/tools/skill-utils.js';
|
||||
import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import type { ManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-storage.js';
|
||||
|
||||
export const HOSTED_SKILL_CATALOG_MAX_BYTES = 48 * 1024;
|
||||
export const HOSTED_SKILL_APPROVAL_REFUSAL =
|
||||
'Hosted Skill loading cannot request approval; the PreToolUse Hook must allow or deny this instruction-only call.';
|
||||
export const HOSTED_SKILL_BATCH_REFUSAL =
|
||||
'Load Skills in a separate tool batch before calling Workspace tools.';
|
||||
|
||||
export interface HostedSkill {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly body: string;
|
||||
readonly base_directory?: string;
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('Invalid Hosted skill catalog.');
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maxBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!/[\ud800-\udfff]/u.test(value) &&
|
||||
Buffer.byteLength(value, 'utf8') <= maxBytes
|
||||
);
|
||||
}
|
||||
|
||||
export function parseHostedSkills(value: unknown): readonly HostedSkill[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > 16 ||
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
HOSTED_SKILL_CATALOG_MAX_BYTES
|
||||
)
|
||||
invalid();
|
||||
const names = new Set<string>();
|
||||
return Object.freeze(
|
||||
value.map((item: unknown) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) invalid();
|
||||
const skill = item as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(skill).some(
|
||||
(key) =>
|
||||
!['name', 'description', 'body', 'base_directory'].includes(key),
|
||||
) ||
|
||||
!boundedText(skill['name'], 128) ||
|
||||
!boundedText(skill['description'], 4096) ||
|
||||
!boundedText(skill['body'], 32 * 1024)
|
||||
)
|
||||
invalid();
|
||||
validateSkillName(skill['name']);
|
||||
if (names.has(skill['name'])) invalid();
|
||||
names.add(skill['name']);
|
||||
const base = skill['base_directory'];
|
||||
if (base !== undefined) {
|
||||
if (
|
||||
!boundedText(base, 4096) ||
|
||||
/[\\:]/u.test(base) ||
|
||||
[...base].some((character) => {
|
||||
const code = character.codePointAt(0)!;
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
}) ||
|
||||
(base !== '.' &&
|
||||
base
|
||||
.split('/')
|
||||
.some(
|
||||
(segment) => !segment || segment === '.' || segment === '..',
|
||||
))
|
||||
)
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
name: skill['name'],
|
||||
description: skill['description'],
|
||||
body: skill['body'],
|
||||
...(base === undefined ? {} : { base_directory: base as string }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readHostedSkills(
|
||||
ref: ManagedSessionDurableRef | undefined,
|
||||
resources: Pick<ManagedSessionResourceStore, 'read'>,
|
||||
): Promise<readonly HostedSkill[]> {
|
||||
if (!ref) return [];
|
||||
if (
|
||||
ref.kind !== 'managed-agent-skills' ||
|
||||
ref.schemaVersion !== 1 ||
|
||||
ref.byteLength > HOSTED_SKILL_CATALOG_MAX_BYTES
|
||||
)
|
||||
throw new Error('Invalid agent skills reference.');
|
||||
const bytes = await resources.read(ref);
|
||||
if (
|
||||
bytes.byteLength !== ref.byteLength ||
|
||||
createHash('sha256').update(bytes).digest('hex') !== ref.digest
|
||||
)
|
||||
throw new Error('Agent skills do not match their reference.');
|
||||
return parseHostedSkills(
|
||||
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)),
|
||||
);
|
||||
}
|
||||
|
||||
export class HostedSkillCatalog {
|
||||
private readonly skills: readonly HostedSkill[];
|
||||
|
||||
constructor(skills: readonly HostedSkill[]) {
|
||||
this.skills = parseHostedSkills(skills);
|
||||
}
|
||||
|
||||
instructions(): string | undefined {
|
||||
if (!this.skills.length) return undefined;
|
||||
return `The following published skills are available. Use the skill tool to load their instructions when relevant.\n${renderAvailableSkillsBlock([...this.skills])}`;
|
||||
}
|
||||
|
||||
declaration(): FunctionDeclaration | undefined {
|
||||
if (!this.skills.length) return undefined;
|
||||
return {
|
||||
name: 'skill',
|
||||
description:
|
||||
'Load the instructions of a published skill in a separate batch before calling other tools. Loading does not run scripts or change tool permissions.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skill: {
|
||||
type: 'string',
|
||||
enum: this.skills.map((skill) => skill.name),
|
||||
},
|
||||
},
|
||||
required: ['skill'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
validationError(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args))
|
||||
return 'Hosted Skill requires only a skill name.';
|
||||
const values = args as Record<string, unknown>;
|
||||
if (Object.keys(values).length !== 1 || typeof values['skill'] !== 'string')
|
||||
return 'Hosted Skill requires only a skill name; arguments and activation options are unsupported.';
|
||||
if (!this.skills.some((skill) => skill.name === values['skill']))
|
||||
return `Skill ${JSON.stringify(values['skill'])} is not published for this Session.`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
response(args: unknown): string {
|
||||
const error = this.validationError(args);
|
||||
if (error) throw new Error(error);
|
||||
const name = (args as { skill: string }).skill;
|
||||
const skill = this.skills.find((entry) => entry.name === name)!;
|
||||
return `Base directory for this skill relative to the Session working directory: ${skill.base_directory ?? '.'}\nResolve asset paths relative to this directory with the Workspace tools.\n\n${skill.body}\n`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
openManagedSession,
|
||||
type ManagedSession,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
|
||||
import {
|
||||
HookEventName,
|
||||
type HookOutput,
|
||||
} from '@qwen-code/qwen-code-core/hooks/types.js';
|
||||
import {
|
||||
HostedHookRecoveryRequiredError,
|
||||
HostedHookSession,
|
||||
} from './hosted-hook-session.js';
|
||||
import type { HostedWorkspaceBroker } from './hosted-workspace-broker.js';
|
||||
import { answerResumedTurnCalls } from './hosted-runtime-recovery.js';
|
||||
import {
|
||||
HOSTED_SKILL_APPROVAL_REFUSAL,
|
||||
HostedSkillCatalog,
|
||||
} from './hosted-skill-catalog.js';
|
||||
|
||||
let root: string;
|
||||
let session: ManagedSession;
|
||||
let output: HookOutput;
|
||||
let hookCommand: string;
|
||||
let hookControl: ReturnType<typeof vi.fn<HostedWorkspaceBroker['hookControl']>>;
|
||||
let broker: HostedWorkspaceBroker;
|
||||
const pin = {
|
||||
catalogId: 'skill-policy',
|
||||
catalogRevision: 1,
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
};
|
||||
const options = { baseUrl: 'http://127.0.0.1:1', token: 'test' };
|
||||
const skills = new HostedSkillCatalog([
|
||||
{
|
||||
name: 'review',
|
||||
description: 'Review code.',
|
||||
body: 'Original secret body.',
|
||||
},
|
||||
{
|
||||
name: 'testing',
|
||||
description: 'Test code.',
|
||||
body: 'Replacement test body.',
|
||||
},
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
hookCommand = '';
|
||||
root = await mkdtemp(path.join(tmpdir(), 'hosted-skill-hook-'));
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId: randomUUID(),
|
||||
};
|
||||
const resources = LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir: root,
|
||||
sessionKey,
|
||||
});
|
||||
session = await openManagedSession({
|
||||
runtimeBaseDir: root,
|
||||
cwd: root,
|
||||
transcriptPath: path.join(root, 'session.jsonl'),
|
||||
sessionId: sessionKey.sessionId,
|
||||
sessionKey,
|
||||
version: 'test',
|
||||
workerId: 'original',
|
||||
activationLeaseDurationMs: 60_000,
|
||||
create: {
|
||||
definitionRef: await resources.publish(
|
||||
'managed-definition',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
approvalMode: 'default',
|
||||
approvalTimeoutMs: 60_000,
|
||||
}),
|
||||
),
|
||||
),
|
||||
rootSnapshotRef: await resources.publish(
|
||||
'managed-root',
|
||||
Buffer.from('{}'),
|
||||
),
|
||||
createdBy: 'test',
|
||||
},
|
||||
});
|
||||
hookControl = vi.fn<HostedWorkspaceBroker['hookControl']>(
|
||||
async (operation) => {
|
||||
if (operation.kind === 'hook-catalog')
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
catalog: {
|
||||
...pin,
|
||||
hooks: [
|
||||
{
|
||||
hookId: 'before',
|
||||
eventName: HookEventName.PreToolUse,
|
||||
sequential: false,
|
||||
async: false,
|
||||
failClosed: true,
|
||||
onceKey: null,
|
||||
config: { type: 'command', command: hookCommand },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
if (operation.kind === 'hook-execute')
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
state: 'settled',
|
||||
result: { success: true, outcome: 'success', duration: 1, output },
|
||||
};
|
||||
throw new Error('Recovery must not call Runtime controls.');
|
||||
},
|
||||
);
|
||||
broker = {
|
||||
runtimeSessionId: 'prompt',
|
||||
runtime: {
|
||||
bindingId: 'binding',
|
||||
generation: '1',
|
||||
workspaceGeneration: '1',
|
||||
},
|
||||
warm: async () => undefined,
|
||||
acquire: async () => undefined,
|
||||
authorizeLifecycle: async () => undefined,
|
||||
hookControl,
|
||||
} as unknown as HostedWorkspaceBroker;
|
||||
await session.sink.write({
|
||||
uuid: randomUUID(),
|
||||
parentUuid: null,
|
||||
sessionId: sessionKey.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: root,
|
||||
version: 'test',
|
||||
daemonPromptId: 'prompt',
|
||||
model: 'model',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call',
|
||||
name: 'skill',
|
||||
args: { skill: 'review' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function firePreTool(
|
||||
fields: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
const hooks = new HostedHookSession(options, session, pin, broker);
|
||||
await hooks.fire(
|
||||
HookEventName.PreToolUse,
|
||||
'prompt:call',
|
||||
{
|
||||
tool_name: 'skill',
|
||||
tool_input: { skill: 'review' },
|
||||
tool_use_id: 'call',
|
||||
permission_mode: 'default',
|
||||
prompt_id: 'prompt',
|
||||
...fields,
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
}
|
||||
|
||||
async function recover(): Promise<number> {
|
||||
const sessionKey = session.authority.sessionHeader.sessionKey;
|
||||
await session.close();
|
||||
session = await openManagedSession({
|
||||
runtimeBaseDir: root,
|
||||
cwd: root,
|
||||
transcriptPath: path.join(root, 'session.jsonl'),
|
||||
sessionId: sessionKey.sessionId,
|
||||
sessionKey,
|
||||
version: 'test',
|
||||
workerId: 'replacement',
|
||||
activationLeaseDurationMs: 60_000,
|
||||
});
|
||||
return answerResumedTurnCalls({
|
||||
session,
|
||||
sessionId: sessionKey.sessionId,
|
||||
cwd: root,
|
||||
promptId: 'prompt',
|
||||
skills,
|
||||
hooks: new HostedHookSession(options, session, pin, broker),
|
||||
});
|
||||
}
|
||||
|
||||
it.each(['deny', 'ask'] as const)(
|
||||
'preserves a saved Hook %s after replacement without exposing the Skill body',
|
||||
async (permissionDecision) => {
|
||||
output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: HookEventName.PreToolUse,
|
||||
permissionDecision,
|
||||
permissionDecisionReason: 'Policy denied the load.',
|
||||
},
|
||||
};
|
||||
await firePreTool();
|
||||
const controls = hookControl.mock.calls.length;
|
||||
expect(await recover()).toBe(1);
|
||||
const history = await session.sink.project();
|
||||
expect(
|
||||
history.at(-1)?.message?.parts?.[0].functionResponse?.response,
|
||||
).toEqual({
|
||||
error:
|
||||
permissionDecision === 'ask'
|
||||
? HOSTED_SKILL_APPROVAL_REFUSAL
|
||||
: 'Policy denied the load.',
|
||||
});
|
||||
expect(JSON.stringify(history.at(-1))).not.toContain(
|
||||
'Original secret body.',
|
||||
);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the saved Hook updatedInput after replacement instead of the original Skill name', async () => {
|
||||
output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: HookEventName.PreToolUse,
|
||||
updatedInput: { skill: 'testing' },
|
||||
},
|
||||
};
|
||||
await firePreTool();
|
||||
const controls = hookControl.mock.calls.length;
|
||||
expect(await recover()).toBe(1);
|
||||
const history = await session.sink.project();
|
||||
expect(
|
||||
history.at(-1)?.message?.parts?.[0].functionResponse?.response?.['output'],
|
||||
).toBe(skills.response({ skill: 'testing' }));
|
||||
expect(JSON.stringify(history.at(-1))).not.toContain('Original secret body.');
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
});
|
||||
|
||||
it('durably refuses an interrupted Skill call before its PreToolUse marker committed', async () => {
|
||||
const original = session.authority.commitExtensionRecord.bind(
|
||||
session.authority,
|
||||
);
|
||||
vi.spyOn(session.authority, 'commitExtensionRecord').mockImplementation(
|
||||
async (...args) => {
|
||||
if ((args[1].record as { hookId?: string }).hookId === '__plan__')
|
||||
throw new Error('Original marker write failed.');
|
||||
return original(...args);
|
||||
},
|
||||
);
|
||||
await expect(firePreTool()).rejects.toThrow('Original marker write failed.');
|
||||
const controls = hookControl.mock.calls.length;
|
||||
expect(await recover()).toBe(1);
|
||||
const history = await session.sink.project();
|
||||
expect(history.map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
'tool_result',
|
||||
]);
|
||||
expect(
|
||||
history.at(-1)?.message?.parts?.[0].functionResponse?.response,
|
||||
).toEqual({
|
||||
error:
|
||||
'This Skill call did not run: no PreToolUse Hook decision was recorded before the Harness was interrupted.',
|
||||
});
|
||||
expect(JSON.stringify(history.at(-1))).not.toContain('Original secret body.');
|
||||
expect(await recover()).toBe(0);
|
||||
expect(await session.sink.project()).toEqual(history);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
});
|
||||
|
||||
it('replays the committed byte-limit refusal after the Hook input was stripped', async () => {
|
||||
hookCommand = 'x'.repeat(70 * 1024);
|
||||
await firePreTool();
|
||||
expect(
|
||||
hookControl.mock.calls.some(
|
||||
([operation]) => operation.kind === 'hook-execute',
|
||||
),
|
||||
).toBe(false);
|
||||
const controls = hookControl.mock.calls.length;
|
||||
expect(await recover()).toBe(1);
|
||||
const history = await session.sink.project();
|
||||
expect(history.map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
'tool_result',
|
||||
]);
|
||||
expect(
|
||||
history.at(-1)?.message?.parts?.[0].functionResponse?.response,
|
||||
).toEqual({
|
||||
error:
|
||||
'Hook data exceeds the 60 KiB resource or 8 MiB control/transaction limit.',
|
||||
});
|
||||
expect(JSON.stringify(history.at(-1))).not.toContain('Original secret body.');
|
||||
expect(await recover()).toBe(0);
|
||||
expect(await session.sink.project()).toEqual(history);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ tool_input: { skill: 'testing' } },
|
||||
{ tool_use_id: 'another-call' },
|
||||
{ prompt_id: 'another-prompt' },
|
||||
{ permission_mode: 'yolo' },
|
||||
])(
|
||||
'rejects a stripped Hook input bound to different fields: %j',
|
||||
async (fields) => {
|
||||
hookCommand = 'x'.repeat(70 * 1024);
|
||||
await firePreTool(fields);
|
||||
const controls = hookControl.mock.calls.length;
|
||||
await expect(recover()).rejects.toBeInstanceOf(
|
||||
HostedHookRecoveryRequiredError,
|
||||
);
|
||||
expect((await session.sink.project()).map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
]);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
},
|
||||
);
|
||||
|
||||
it('blocks recovery while the original PreToolUse result is uncommitted', async () => {
|
||||
output = { decision: 'allow' };
|
||||
const original = session.authority.commitExtensionRecord.bind(
|
||||
session.authority,
|
||||
);
|
||||
vi.spyOn(session.authority, 'commitExtensionRecord').mockImplementation(
|
||||
async (...args) => {
|
||||
const record = args[1].record as { hookId?: string; resultRef?: unknown };
|
||||
if (record.hookId === '__plan__' && record.resultRef)
|
||||
throw new Error('Original aggregate write failed.');
|
||||
return original(...args);
|
||||
},
|
||||
);
|
||||
await expect(firePreTool()).rejects.toThrow(
|
||||
'Original aggregate write failed.',
|
||||
);
|
||||
const controls = hookControl.mock.calls.length;
|
||||
await expect(recover()).rejects.toBeInstanceOf(
|
||||
HostedHookRecoveryRequiredError,
|
||||
);
|
||||
expect((await session.sink.project()).map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
]);
|
||||
expect(hookControl).toHaveBeenCalledTimes(controls);
|
||||
});
|
||||
@@ -69,6 +69,12 @@ import {
|
||||
} from './hosted-tool-approval.js';
|
||||
import * as stdio from '../utils/stdioHelpers.js';
|
||||
import { monitorUnitNameOf } from '@qwen-code/qwen-code-core/managed-runtime/managed-monitor-protocol.js';
|
||||
import {
|
||||
HOSTED_SKILL_APPROVAL_REFUSAL,
|
||||
HOSTED_SKILL_BATCH_REFUSAL,
|
||||
HostedSkillCatalog,
|
||||
} from './hosted-skill-catalog.js';
|
||||
import { answerResumedTurnCalls } from './hosted-runtime-recovery.js';
|
||||
|
||||
const broker = vi.hoisted(() => ({
|
||||
fileHistory: vi.fn(),
|
||||
@@ -166,6 +172,7 @@ function createTurn(
|
||||
shell = false,
|
||||
approval?: { mode: HostedApprovalMode; timeoutMs?: number },
|
||||
hooks?: HostedHookSession,
|
||||
skills?: HostedSkillCatalog,
|
||||
) {
|
||||
return new HostedWorkspaceToolTurn(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
@@ -185,7 +192,7 @@ function createTurn(
|
||||
},
|
||||
waiters,
|
||||
},
|
||||
{ hooks },
|
||||
{ hooks, skills },
|
||||
);
|
||||
}
|
||||
const calls = ['read_file', 'edit'].map((name, index) => ({
|
||||
@@ -294,6 +301,300 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const publishedSkills = () =>
|
||||
new HostedSkillCatalog([
|
||||
{
|
||||
name: 'review',
|
||||
description: 'Review code changes.',
|
||||
body: 'Consult scripts/review.js before reviewing.',
|
||||
base_directory: '.qwen/skills/review',
|
||||
},
|
||||
]);
|
||||
|
||||
it('loads a published static Skill durably without Runtime execution or approval', async () => {
|
||||
const skills = publishedSkills();
|
||||
turn = createTurn(false, { mode: 'default' }, undefined, skills);
|
||||
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
|
||||
const original: Part[] = [
|
||||
{ functionCall: { id: call.callId, name: call.name, args: call.args } },
|
||||
];
|
||||
expect(await turn.declarations(new AbortController().signal)).toContainEqual(
|
||||
skills.declaration(),
|
||||
);
|
||||
const responses = await turn.execute(
|
||||
[call],
|
||||
original,
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['output']).toBe(
|
||||
skills.response(call.args),
|
||||
);
|
||||
const history = await session.sink.project();
|
||||
expect(history.map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
'tool_result',
|
||||
]);
|
||||
expect(history[1].message?.parts).toEqual(responses);
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
expect(
|
||||
session.authority
|
||||
.eventsInSequenceRange(1, session.authority.committedSequence)
|
||||
.some((event) => event.kind === 'action.changed'),
|
||||
).toBe(false);
|
||||
await turn.finish();
|
||||
const reloaded = createTurn(false, undefined, undefined, skills);
|
||||
await reloaded.resumeCommittedResults(new AbortController().signal);
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a mixed Skill and Workspace batch before approval Hooks or tool dispatch', async () => {
|
||||
const fire = vi.fn<HostedHookSession['fire']>().mockResolvedValue(undefined);
|
||||
turn = createTurn(
|
||||
false,
|
||||
{ mode: 'default' },
|
||||
hookSession(fire),
|
||||
publishedSkills(),
|
||||
);
|
||||
const batch = [
|
||||
{ ...calls[0], name: 'skill', args: { skill: 'review' } },
|
||||
{
|
||||
...calls[1],
|
||||
name: 'write_file',
|
||||
args: { file_path: 'output.txt', content: 'must not write' },
|
||||
},
|
||||
];
|
||||
const responses = await turn.execute(
|
||||
batch,
|
||||
batch.map((call) => ({
|
||||
functionCall: { id: call.callId, name: call.name, args: call.args },
|
||||
})),
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toBe(
|
||||
HOSTED_SKILL_BATCH_REFUSAL,
|
||||
);
|
||||
expect(responses[1].functionResponse?.response?.['error']).toContain(
|
||||
'not executed',
|
||||
);
|
||||
expect(actionIds()).toEqual([]);
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
expect(
|
||||
session.authority
|
||||
.eventsInSequenceRange(1, session.authority.committedSequence)
|
||||
.some((event) => event.kind === 'hosted.batch.planned'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([{ skill: 'unknown' }, { skill: 'review', args: '--post' }])(
|
||||
'persists a correctable Skill refusal without executing anything: %j',
|
||||
async (args) => {
|
||||
turn = createTurn(false, undefined, undefined, publishedSkills());
|
||||
const call = { ...calls[0], name: 'skill', args };
|
||||
const responses = await turn.execute(
|
||||
[call],
|
||||
[{ functionCall: { id: call.callId, name: call.name, args } }],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toBeTypeOf(
|
||||
'string',
|
||||
);
|
||||
expect((await session.sink.project()).map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
'tool_result',
|
||||
]);
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('recovers the original Skill body after the assistant commits but its result does not', async () => {
|
||||
const skills = publishedSkills();
|
||||
const originalCommit = commit;
|
||||
commit = async (...args) => {
|
||||
if (args[0] === 'tool_result') throw new Error('Store response lost');
|
||||
return originalCommit(...args);
|
||||
};
|
||||
turn = createTurn(false, undefined, undefined, skills);
|
||||
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
|
||||
await expect(
|
||||
turn.execute(
|
||||
[call],
|
||||
[{ functionCall: { id: call.callId, name: call.name, args: call.args } }],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toBeInstanceOf(HostedToolRecoveryRequiredError);
|
||||
expect((await session.sink.project()).map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
]);
|
||||
const recovery = {
|
||||
session,
|
||||
sessionId: session.authority.sessionHeader.sessionKey.sessionId,
|
||||
cwd: root,
|
||||
promptId: 'prompt',
|
||||
skills,
|
||||
};
|
||||
expect(await answerResumedTurnCalls(recovery)).toBe(1);
|
||||
expect(await answerResumedTurnCalls(recovery)).toBe(0);
|
||||
const history = await session.sink.project();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[1].message?.parts?.[0].functionResponse).toMatchObject({
|
||||
id: call.callId,
|
||||
name: 'skill',
|
||||
response: { output: skills.response(call.args) },
|
||||
});
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves a mixed-batch Skill refusal when its result was not committed', async () => {
|
||||
const skills = publishedSkills();
|
||||
const originalCommit = commit;
|
||||
commit = async (...args) => {
|
||||
if (args[0] === 'tool_result') throw new Error('Store refusal lost');
|
||||
return originalCommit(...args);
|
||||
};
|
||||
turn = createTurn(false, { mode: 'default' }, undefined, skills);
|
||||
const batch = [
|
||||
{ ...calls[0], name: 'skill', args: { skill: 'review' } },
|
||||
calls[1],
|
||||
];
|
||||
await expect(
|
||||
turn.execute(
|
||||
batch,
|
||||
batch.map((call) => ({
|
||||
functionCall: { id: call.callId, name: call.name, args: call.args },
|
||||
})),
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toBeInstanceOf(HostedToolRecoveryRequiredError);
|
||||
expect((await session.sink.project()).map((entry) => entry.type)).toEqual([
|
||||
'assistant',
|
||||
]);
|
||||
const recovery = {
|
||||
session,
|
||||
sessionId: session.authority.sessionHeader.sessionKey.sessionId,
|
||||
cwd: root,
|
||||
promptId: 'prompt',
|
||||
skills,
|
||||
};
|
||||
expect(await answerResumedTurnCalls(recovery)).toBe(1);
|
||||
expect(await answerResumedTurnCalls(recovery)).toBe(0);
|
||||
const history = await session.sink.project();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[1].message?.parts?.[0].functionResponse).toMatchObject({
|
||||
id: batch[0].callId,
|
||||
name: 'skill',
|
||||
response: { error: HOSTED_SKILL_BATCH_REFUSAL },
|
||||
});
|
||||
expect(JSON.stringify(history[1])).not.toContain(
|
||||
'Consult scripts/review.js before reviewing.',
|
||||
);
|
||||
expect(actionIds()).toEqual([]);
|
||||
expect(broker.acquire).not.toHaveBeenCalled();
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['deny', 'ask'] as const)(
|
||||
'preserves a PreToolUse %s for a static Skill without creating an Action',
|
||||
async (permissionDecision) => {
|
||||
const fire = vi.fn<HostedHookSession['fire']>(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: event,
|
||||
permissionDecision,
|
||||
permissionDecisionReason: 'Skill policy refused the load.',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(
|
||||
false,
|
||||
{ mode: 'default' },
|
||||
hookSession(fire),
|
||||
publishedSkills(),
|
||||
);
|
||||
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
|
||||
const responses = await turn.execute(
|
||||
[call],
|
||||
[{ functionCall: { id: call.callId, name: call.name, args: call.args } }],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['error']).toBe(
|
||||
permissionDecision === 'ask'
|
||||
? HOSTED_SKILL_APPROVAL_REFUSAL
|
||||
: 'Skill policy refused the load.',
|
||||
);
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
expect(actionIds()).toEqual([]);
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('loads the Skill selected by PreToolUse updatedInput without a new approval', async () => {
|
||||
const skills = new HostedSkillCatalog([
|
||||
{
|
||||
name: 'review',
|
||||
description: 'Review code.',
|
||||
body: 'Original review body.',
|
||||
},
|
||||
{
|
||||
name: 'testing',
|
||||
description: 'Test code.',
|
||||
body: 'Replacement test body.',
|
||||
},
|
||||
]);
|
||||
const fire = vi.fn<HostedHookSession['fire']>(async (event) =>
|
||||
event === HookEventName.PreToolUse
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: event,
|
||||
updatedInput: { skill: 'testing' },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
turn = createTurn(false, { mode: 'default' }, hookSession(fire), skills);
|
||||
const call = { ...calls[0], name: 'skill', args: { skill: 'review' } };
|
||||
const responses = await turn.execute(
|
||||
[call],
|
||||
[{ functionCall: { id: call.callId, name: call.name, args: call.args } }],
|
||||
'model',
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(responses[0].functionResponse?.response?.['output']).toBe(
|
||||
skills.response({ skill: 'testing' }),
|
||||
);
|
||||
expect(fire.mock.calls.map(([event]) => event)).toEqual([
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PostToolBatch,
|
||||
]);
|
||||
expect(actionIds()).toEqual([]);
|
||||
expect(broker.prepare).not.toHaveBeenCalled();
|
||||
expect(broker.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits the whole batch before the first dispatch and each receipt before resolving it', async () => {
|
||||
const original = harness.resolveAwaitRuntime.bind(harness);
|
||||
vi.spyOn(harness, 'resolveAwaitRuntime').mockImplementation(
|
||||
|
||||
@@ -182,6 +182,11 @@ import { hostedHookOccurrenceId } from './hosted-hook-session.js';
|
||||
import { parseHookExecution } from '@qwen-code/qwen-code-core/managed-runtime/managed-hook-record.js';
|
||||
import { waitForTurn } from './hosted-turn-wait.js';
|
||||
import { boundedShellPreview } from './managed-shell-publisher.js';
|
||||
import {
|
||||
HOSTED_SKILL_APPROVAL_REFUSAL,
|
||||
HOSTED_SKILL_BATCH_REFUSAL,
|
||||
type HostedSkillCatalog,
|
||||
} from './hosted-skill-catalog.js';
|
||||
import {
|
||||
endHostedAction,
|
||||
HOSTED_APPROVAL_OPTIONS,
|
||||
@@ -1472,6 +1477,7 @@ interface HostedPreparedRequest {
|
||||
team: boolean;
|
||||
message: boolean;
|
||||
command: boolean;
|
||||
skill: boolean;
|
||||
}
|
||||
|
||||
export class HostedWorkspaceToolTurn {
|
||||
@@ -1526,6 +1532,7 @@ export class HostedWorkspaceToolTurn {
|
||||
private readonly teams?: HostedTeamSession;
|
||||
/** H4e-b3: the member's command funnel to its parent, beside its messages. */
|
||||
private readonly commands?: HostedSessionCommandSession;
|
||||
private readonly skills?: HostedSkillCatalog;
|
||||
/** b3b: whether this Session's run carries a member preamble (once read). */
|
||||
private memberPreamble?: boolean;
|
||||
/** b3b: the member board vocabulary declared, refreshed per declarations. */
|
||||
@@ -1583,6 +1590,7 @@ export class HostedWorkspaceToolTurn {
|
||||
messages?: HostedSessionMessageSession;
|
||||
teams?: HostedTeamSession;
|
||||
commands?: HostedSessionCommandSession;
|
||||
skills?: HostedSkillCatalog;
|
||||
/**
|
||||
* H4e-b3 decisions 5/9: this run's first input carried an unescaped
|
||||
* `plan-mode="required"` preamble; its calls admit reads only.
|
||||
@@ -1605,6 +1613,7 @@ export class HostedWorkspaceToolTurn {
|
||||
this.messages = extras?.messages;
|
||||
this.teams = extras?.teams;
|
||||
this.commands = extras?.commands;
|
||||
this.skills = extras?.skills;
|
||||
this.memberPlanMode = extras?.memberPlanMode === true;
|
||||
this.publication =
|
||||
publicationOrShell && 'owner' in publicationOrShell
|
||||
@@ -1662,6 +1671,7 @@ export class HostedWorkspaceToolTurn {
|
||||
// b3c: a member's widened send_message rides the same gate as its
|
||||
// board commands — resolved once, both declarations follow it.
|
||||
const memberCommands = await this.memberCommandsDeclared();
|
||||
const skill = this.skills?.declaration();
|
||||
this.advertised = [
|
||||
...(this.publication || this.shell
|
||||
? search
|
||||
@@ -1683,6 +1693,7 @@ export class HostedWorkspaceToolTurn {
|
||||
: tool,
|
||||
),
|
||||
...(this.mcp?.tools() ?? []),
|
||||
...(skill ? [skill] : []),
|
||||
// H4b: the Agent tool joins the declaration set only on a
|
||||
// Shell-laned Session whose own child orchestrator exists (never in
|
||||
// a child's), behind the kind gate — files profiles and the public
|
||||
@@ -1912,6 +1923,26 @@ export class HostedWorkspaceToolTurn {
|
||||
|
||||
async resumeCommittedResults(signal?: AbortSignal): Promise<void> {
|
||||
if (this.acquired) return;
|
||||
if (this.skills && !this.hooks && !this.mcp) {
|
||||
const history = (await this.session.sink.project()).filter(
|
||||
(entry) => entry.daemonPromptId === this.promptId,
|
||||
);
|
||||
const calls = history.flatMap((entry) =>
|
||||
(entry.message?.parts ?? []).flatMap((part) =>
|
||||
part.functionCall ? [part.functionCall] : [],
|
||||
),
|
||||
);
|
||||
const answered = await journaledToolResultIds(
|
||||
this.session,
|
||||
this.promptId,
|
||||
history,
|
||||
);
|
||||
if (
|
||||
calls.length &&
|
||||
calls.every((call) => call.name === 'skill' && answered.has(call.id!))
|
||||
)
|
||||
return;
|
||||
}
|
||||
await this.warmed;
|
||||
const saved = await readHostedFileHistory(this.session);
|
||||
if (saved?.pendingTurn) {
|
||||
@@ -2333,7 +2364,8 @@ export class HostedWorkspaceToolTurn {
|
||||
intents.some((entry) => entry.payload['ordinal'] === ordinal) ||
|
||||
// A team tool commits only to the journal and answers a refusal as
|
||||
// its error, so its committed answer is its own dispatch evidence.
|
||||
((HOSTED_TEAM_TOOL_NAMES.includes(call.name) ||
|
||||
(((call.name === 'skill' && this.skills !== undefined) ||
|
||||
HOSTED_TEAM_TOOL_NAMES.includes(call.name) ||
|
||||
(call.name === 'exit_plan_mode' &&
|
||||
this.commands?.command(
|
||||
sessionCommandId({
|
||||
@@ -2571,6 +2603,7 @@ export class HostedWorkspaceToolTurn {
|
||||
team: false,
|
||||
message: false,
|
||||
command: false,
|
||||
skill: item.call.name === 'skill' && this.skills !== undefined,
|
||||
});
|
||||
}
|
||||
this.logicalRound = plan.round;
|
||||
@@ -2749,7 +2782,11 @@ export class HostedWorkspaceToolTurn {
|
||||
const declarations = this.advertised ?? (await this.declarations(signal));
|
||||
this.hookPermission.clear();
|
||||
this.agentDispatched.clear();
|
||||
if (this.hooks && this.approval) {
|
||||
const mixedSkillBatch =
|
||||
this.skills !== undefined &&
|
||||
calls.some((call) => call.name === 'skill') &&
|
||||
calls.some((call) => call.name !== 'skill');
|
||||
if (this.hooks && this.approval && !mixedSkillBatch) {
|
||||
calls = await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
const asks = hostedApprovalAsks(
|
||||
@@ -2757,7 +2794,7 @@ export class HostedWorkspaceToolTurn {
|
||||
call.name,
|
||||
this.searchProfile,
|
||||
);
|
||||
if (!asks) return call;
|
||||
if (!asks || (call.name === 'skill' && this.skills)) return call;
|
||||
const output = await this.hooks!.fire(
|
||||
HookEventName.PermissionRequest,
|
||||
`${this.promptId}:${call.callId}`,
|
||||
@@ -2802,6 +2839,10 @@ export class HostedWorkspaceToolTurn {
|
||||
runtimeCallId,
|
||||
);
|
||||
const isShell = call.name === 'run_shell_command';
|
||||
const skill =
|
||||
mcpInput === undefined &&
|
||||
call.name === 'skill' &&
|
||||
this.skills !== undefined;
|
||||
if (
|
||||
!declarations.some((tool) => tool.name === call.name) ||
|
||||
ids.has(call.callId) ||
|
||||
@@ -2859,6 +2900,11 @@ export class HostedWorkspaceToolTurn {
|
||||
input = { ...call.args };
|
||||
} else if (mcpInput) {
|
||||
input = { ...mcpInput.input };
|
||||
} else if (skill) {
|
||||
validationError = source.some((other) => other.name !== 'skill')
|
||||
? HOSTED_SKILL_BATCH_REFUSAL
|
||||
: this.skills!.validationError(call.args);
|
||||
input = { ...call.args };
|
||||
} else if (isShell) {
|
||||
const args = call.args;
|
||||
const unsupportedKey = Object.keys(args).find(
|
||||
@@ -3422,6 +3468,7 @@ export class HostedWorkspaceToolTurn {
|
||||
mcpInput === undefined && messageAdmitted && !memberMessageCommand,
|
||||
team,
|
||||
command,
|
||||
skill,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -3473,7 +3520,8 @@ export class HostedWorkspaceToolTurn {
|
||||
throw new HostedToolRecoveryRequiredError(cause);
|
||||
}
|
||||
}
|
||||
await waitForTurn(this.warmed, signal);
|
||||
if (this.hooks || this.mcp || requests.some((request) => !request.skill))
|
||||
await waitForTurn(this.warmed, signal);
|
||||
// H4b: an agent-only batch runs entirely inside its own Sessions and
|
||||
// needs no Runtime at all — holding the parent's mount across the
|
||||
// child's wait is exactly the deadlock a shared Workspace creates
|
||||
@@ -3491,7 +3539,8 @@ export class HostedWorkspaceToolTurn {
|
||||
request.workflow !== true &&
|
||||
request.message !== true &&
|
||||
!request.team &&
|
||||
request.command !== true,
|
||||
request.command !== true &&
|
||||
!request.skill,
|
||||
)
|
||||
) {
|
||||
// Acquisition may have taken effect even when its reply is lost.
|
||||
@@ -3640,6 +3689,10 @@ export class HostedWorkspaceToolTurn {
|
||||
'PreToolUse Hook denied the call.';
|
||||
continue;
|
||||
}
|
||||
if (request.skill && permission === 'ask') {
|
||||
refusals[index] = HOSTED_SKILL_APPROVAL_REFUSAL;
|
||||
continue;
|
||||
}
|
||||
const input = output?.hookSpecificOutput?.['updatedInput'];
|
||||
if (
|
||||
input &&
|
||||
@@ -3654,6 +3707,7 @@ export class HostedWorkspaceToolTurn {
|
||||
inputRefs.delete(index);
|
||||
if (
|
||||
this.approval &&
|
||||
!request.skill &&
|
||||
hostedApprovalAsks(
|
||||
this.approval.settings,
|
||||
request.call.name,
|
||||
@@ -3806,6 +3860,7 @@ export class HostedWorkspaceToolTurn {
|
||||
request.message ||
|
||||
request.team ||
|
||||
request.command ||
|
||||
request.skill ||
|
||||
request.workflow
|
||||
)
|
||||
continue;
|
||||
@@ -4243,6 +4298,20 @@ export class HostedWorkspaceToolTurn {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (request.skill) {
|
||||
const converted = convertToFunctionResponse(
|
||||
request.call.name,
|
||||
request.call.callId,
|
||||
[{ text: this.skills!.response(request.call.args) }],
|
||||
);
|
||||
if (!this.messageFitsInline('tool_result', converted, model))
|
||||
throw new Error(
|
||||
'Hosted Skill result exceeds the inline Session Store limit.',
|
||||
);
|
||||
await this.commit('tool_result', converted, model);
|
||||
responses.push(...converted);
|
||||
continue;
|
||||
}
|
||||
if (request.team) {
|
||||
responses.push(
|
||||
...(await this.acceptTeamTool(request, model, signal)),
|
||||
@@ -6813,6 +6882,7 @@ export class HostedWorkspaceToolTurn {
|
||||
if (index < nextOrdinal) continue;
|
||||
if (
|
||||
refusals[index] ||
|
||||
(request.call.name === 'skill' && this.skills !== undefined) ||
|
||||
this.hookPermission.get(request.call.callId) === 'allow' ||
|
||||
!hostedApprovalAsks(approval.settings, request.call.name, searchProfile)
|
||||
)
|
||||
|
||||
@@ -941,6 +941,75 @@ describe('migration profile evidence', () => {
|
||||
});
|
||||
|
||||
describe('verifyRecoverySession', () => {
|
||||
it.each([
|
||||
[
|
||||
'published catalog',
|
||||
Buffer.from(
|
||||
'[{"name":"review","description":"Review","body":"Check the diff."}]',
|
||||
),
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'corrupt bytes',
|
||||
Buffer.from('[]'),
|
||||
Buffer.from('{}'),
|
||||
'resource bytes conflict',
|
||||
],
|
||||
['missing bytes', Buffer.from('[]'), null, 'missing_resource'],
|
||||
[
|
||||
'oversized catalog',
|
||||
Buffer.alloc(48 * 1024 + 1, 0x20),
|
||||
undefined,
|
||||
'Invalid agent skills reference',
|
||||
],
|
||||
[
|
||||
'invalid UTF-8',
|
||||
Buffer.from([0xff]),
|
||||
undefined,
|
||||
'The encoded data was not valid for encoding utf-8',
|
||||
],
|
||||
[
|
||||
'invalid catalog',
|
||||
Buffer.from('[{"name":"review"}]'),
|
||||
undefined,
|
||||
'Invalid Hosted skill catalog',
|
||||
],
|
||||
] as const)(
|
||||
'verifies pinned Skills through the recovery closure: %s',
|
||||
async (_name, bytes, stored, error) => {
|
||||
const skillsRef: ManagedSessionDurableRef = {
|
||||
resourceId: 'agent-skills',
|
||||
kind: 'managed-agent-skills',
|
||||
schemaVersion: 1,
|
||||
byteLength: bytes.length,
|
||||
digest: hash(bytes),
|
||||
};
|
||||
const f = fixture({
|
||||
toolProfile: 'hosted-workspace-files/1',
|
||||
agentDefinition: {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
skillsRef,
|
||||
},
|
||||
});
|
||||
if (stored !== null)
|
||||
f.resources.set(skillsRef.resourceId, stored ?? bytes);
|
||||
if (error) {
|
||||
await expect(verifyRecoverySession(f.source, f.io)).rejects.toThrow(
|
||||
error,
|
||||
);
|
||||
expect(f.complete.has(skillsRef.resourceId)).toBe(false);
|
||||
} else {
|
||||
await expect(verifyRecoverySession(f.source, f.io)).resolves.toEqual({
|
||||
fileHistory: 'not_captured',
|
||||
});
|
||||
expect(f.complete.has(skillsRef.resourceId)).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('captures raw pinned agent instructions and refuses changed bytes', async () => {
|
||||
const bytes = Buffer.from('Keep the agent instructions. 保留指令。');
|
||||
const instructionsRef: ManagedSessionDurableRef = {
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from './managed-workspace-binding.js';
|
||||
import { parseHostedFileHistoryRecord } from './hosted-file-history-protocol.js';
|
||||
import { readHostedApprovalDefinition } from './hosted-tool-approval.js';
|
||||
import { readHostedSkills } from './hosted-skill-catalog.js';
|
||||
import {
|
||||
isHostedWorkspaceProfile,
|
||||
isHostedWorkspaceShellProfile,
|
||||
@@ -176,6 +177,7 @@ const DOMAINS = [
|
||||
const RESOURCE_KINDS = new Set([
|
||||
'managed-definition',
|
||||
'managed-agent-instructions',
|
||||
'managed-agent-skills',
|
||||
'managed-root',
|
||||
'managed-input',
|
||||
'managed-admission',
|
||||
@@ -1051,6 +1053,8 @@ export async function verifyRecoverySession(
|
||||
'agent instructions exceed 64 KiB',
|
||||
);
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} else if (ref.kind === 'managed-agent-skills') {
|
||||
await readHostedSkills(ref, { read: async () => bytes });
|
||||
} else if (ref.kind === 'managed-api-history') {
|
||||
const history = json(bytes);
|
||||
requireValue(Array.isArray(history), 'invalid API history');
|
||||
|
||||
@@ -128,70 +128,147 @@ describe('HTTP Managed Session store', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('includes digest-addressed instructions in the definition transaction closure', async () => {
|
||||
const server = new FakeManagedSessionStore();
|
||||
const runtimeBaseDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'definition-closure-'),
|
||||
);
|
||||
temporaryDirectories.push(runtimeBaseDir);
|
||||
const stores = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
sessionKey: SESSION_KEY,
|
||||
writerId: 'harness-a',
|
||||
writerToken: TOKEN_A,
|
||||
fetchFn: server.fetch,
|
||||
});
|
||||
const instructionsRef = await stores.resourceStore.publish(
|
||||
'managed-agent-instructions',
|
||||
Buffer.from('Definition instructions'),
|
||||
);
|
||||
const definitionRef = await stores.resourceStore.publish(
|
||||
'managed-definition',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
engine: 'managed',
|
||||
agentDefinition: {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
instructionsRef,
|
||||
it.each(['instructions', 'skills', 'both'])(
|
||||
'includes nested %s after the definition leaves the staged transaction closure',
|
||||
async (extensions) => {
|
||||
const server = new FakeManagedSessionStore();
|
||||
const runtimeBaseDir = await mkdtemp(
|
||||
path.join(tmpdir(), 'definition-closure-'),
|
||||
);
|
||||
temporaryDirectories.push(runtimeBaseDir);
|
||||
const stores = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
sessionKey: SESSION_KEY,
|
||||
writerId: 'harness-a',
|
||||
writerToken: TOKEN_A,
|
||||
fetchFn: server.fetch,
|
||||
});
|
||||
const instructionsBytes = Buffer.from('Definition instructions');
|
||||
const skillsBytes = Buffer.from(
|
||||
JSON.stringify([
|
||||
{ name: 'review', description: 'Review', body: 'Read the diff.' },
|
||||
]),
|
||||
);
|
||||
const instructionsRef = await stores.resourceStore.publish(
|
||||
'managed-agent-instructions',
|
||||
instructionsBytes,
|
||||
);
|
||||
const skillsRef = await stores.resourceStore.publish(
|
||||
'managed-agent-skills',
|
||||
skillsBytes,
|
||||
);
|
||||
const nested = [
|
||||
...(extensions !== 'skills' ? [instructionsRef] : []),
|
||||
...(extensions !== 'instructions' ? [skillsRef] : []),
|
||||
];
|
||||
const definitionRef = await stores.resourceStore.publish(
|
||||
'managed-definition',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
engine: 'managed',
|
||||
agentDefinition: {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
...(extensions !== 'skills' ? { instructionsRef } : {}),
|
||||
...(extensions !== 'instructions' ? { skillsRef } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const rootSnapshotRef = await stores.resourceStore.publish(
|
||||
'managed-root',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
const session = await openManagedSession({
|
||||
runtimeBaseDir,
|
||||
sessionId: SESSION_KEY.sessionId,
|
||||
transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'),
|
||||
sessionKey: SESSION_KEY,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
workerId: 'harness-a',
|
||||
activationLeaseDurationMs: 60000,
|
||||
journalStore: stores.journalStore,
|
||||
resourceStore: stores.resourceStore,
|
||||
create: { definitionRef, rootSnapshotRef, createdBy: 'test' },
|
||||
});
|
||||
expect(server.commits[0]?.['resources']).toEqual(
|
||||
expect.arrayContaining(
|
||||
nested.map((ref) => expect.objectContaining(ref)),
|
||||
),
|
||||
);
|
||||
const recoveryRef = await session.resources.publish(
|
||||
'hosted-model-request',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
sessionKey: SESSION_KEY,
|
||||
promptId: 'prompt',
|
||||
definitionRef,
|
||||
rootSnapshotRef,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await session.authority.appendExecutionEvent(
|
||||
{
|
||||
operation: 'model.attempt',
|
||||
commandId: 'model-1',
|
||||
sessionKey: SESSION_KEY,
|
||||
contentDigest: 'e'.repeat(64),
|
||||
},
|
||||
(sequence) => ({
|
||||
v: 1,
|
||||
sequence,
|
||||
eventId: 'model-1',
|
||||
sessionKey: SESSION_KEY,
|
||||
kind: 'model.attempt',
|
||||
occurredAt: Date.now(),
|
||||
subject: {
|
||||
type: 'activation',
|
||||
scopeId: session.activation.activationId,
|
||||
...session.activation,
|
||||
},
|
||||
payload: {
|
||||
attemptId: 'prompt:main:0',
|
||||
routeRef: rootSnapshotRef,
|
||||
inputCheckpointRef: null,
|
||||
state: 'started',
|
||||
usageRef: null,
|
||||
recoveryRef,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const rootSnapshotRef = await stores.resourceStore.publish(
|
||||
'managed-root',
|
||||
Buffer.from('{}'),
|
||||
);
|
||||
const session = await openManagedSession({
|
||||
runtimeBaseDir,
|
||||
sessionId: SESSION_KEY.sessionId,
|
||||
transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'),
|
||||
sessionKey: SESSION_KEY,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
workerId: 'harness-a',
|
||||
activationLeaseDurationMs: 60000,
|
||||
journalStore: stores.journalStore,
|
||||
resourceStore: stores.resourceStore,
|
||||
create: { definitionRef, rootSnapshotRef, createdBy: 'test' },
|
||||
});
|
||||
expect(await stores.resourceStore.read(instructionsRef)).toEqual(
|
||||
Buffer.from('Definition instructions'),
|
||||
);
|
||||
await session.close();
|
||||
const restored = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
sessionKey: SESSION_KEY,
|
||||
writerId: 'harness-b',
|
||||
writerToken: TOKEN_B,
|
||||
fetchFn: server.fetch,
|
||||
});
|
||||
await restored.journalStore.open({ sessionKey: SESSION_KEY });
|
||||
expect(await restored.resourceStore.read(instructionsRef)).toEqual(
|
||||
Buffer.from('Definition instructions'),
|
||||
);
|
||||
});
|
||||
{ class: 'harness', activation: session.activation },
|
||||
);
|
||||
expect(server.commits.at(-1)?.['resources']).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining(definitionRef),
|
||||
...nested.map((ref) => expect.objectContaining(ref)),
|
||||
]),
|
||||
);
|
||||
for (const ref of [definitionRef, ...nested])
|
||||
expect(
|
||||
(
|
||||
server.commits.at(-1)?.['resources'] as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
).find((entry) => entry['resourceId'] === ref.resourceId),
|
||||
).not.toHaveProperty('bytesBase64');
|
||||
await session.close();
|
||||
const restored = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
sessionKey: SESSION_KEY,
|
||||
writerId: 'harness-b',
|
||||
writerToken: TOKEN_B,
|
||||
fetchFn: server.fetch,
|
||||
});
|
||||
await restored.journalStore.open({ sessionKey: SESSION_KEY });
|
||||
for (const ref of nested)
|
||||
expect(await restored.resourceStore.read(ref)).toEqual(
|
||||
ref === instructionsRef ? instructionsBytes : skillsBytes,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -1907,6 +1907,7 @@ function isReferenceContainer(kind: string): boolean {
|
||||
EXTENSION_RECORD_KINDS.has(kind) ||
|
||||
HOSTED_RECOVERY_RESOURCE_KINDS.has(kind) ||
|
||||
[
|
||||
'managed-definition',
|
||||
'managed-checkpoint',
|
||||
'managed-file_history',
|
||||
'managed-action-options',
|
||||
|
||||
@@ -1397,8 +1397,14 @@ The target marker is the sole manifest exception and must match the copied sourc
|
||||
|
||||
### Stored agent execution
|
||||
|
||||
Sessions pin a stored AgentDefinition revision and digest at admission. `permission_policy` and `tools` select the Session's immutable approval mode and hosted tool profile. `model` accepts `{}` or `{ "id": "configured-model" }`; `instructions` accepts UTF-8 text up to 64 KiB. Skills, MCP servers, environment templates and per-definition approval timeouts remain unsupported and are refused explicitly.
|
||||
Sessions pin a stored AgentDefinition revision and digest at admission. `permission_policy` and `tools` select the Session's immutable approval mode and hosted tool profile. `model` accepts `{}` or `{ "id": "configured-model" }`; `instructions` accepts UTF-8 text up to 64 KiB. Static Skills and administrator-provisioned MCP/Hook pins are supported as described below. Environment templates and per-definition approval timeouts remain unsupported and are refused explicitly.
|
||||
|
||||
Set `QWEN_MANAGED_AGENT_DEFINITION_MODELS` to comma-separated IDs configured in the Harness's deployment `modelProviders`; it defaults to empty. Each ID must resolve to one configured provider route with deployment credentials. An absent or ambiguous route or unresolved credentials fails the Turn with `model_unavailable` before inference. Credentials never belong in definitions. Enable and configure the Managed Session Store for stored-definition execution; instructions are delivered as digest-addressed resource references and verified on load.
|
||||
|
||||
`QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES` defaults to `hosted-workspace-files/1`. Before opting into `hosted-workspace-files/2`, drain old workers and upgrade every provisioner according to #13166. Empty permission policies inherit the deployment approval mode (default `yolo`); use `default` for deployments that require approval.
|
||||
|
||||
`skills` accepts up to 16 static `{ "name": "review", "description": "Review changes", "body": "Read the diff and report concrete defects.", "base_directory": ".qwen/skills/review" }` entries for a Workspace-bound tool profile. `base_directory` is optional and defaults to `.`; it is a canonical POSIX path relative to the Session's working directory, matching Runtime `read_file` and `glob`, never the Harness host. Absolute paths, traversal, empty or dot components, colons, backslashes and control characters are rejected. Names are unique and use letters, numbers, `_`, `:`, `.`, or `-` (at most 128 UTF-8 bytes); descriptions are nonblank and at most 4096 UTF-8 bytes, bodies are nonblank and at most 32768 bytes, directories are at most 4096 bytes, and the compact JSON catalog is at most 48 KiB. Invalid Unicode and extra Skill fields are rejected. Bodies grant no tool permissions and cannot register Hooks, override the model, or discover host-installed skills. The connector publishes the catalog as a digest-addressed `managed-agent-skills` resource; a replacement Harness validates and reads the original reference.
|
||||
|
||||
`mcp_servers` accepts up to 16 unique `{ "server_id": "docs", "server_revision": 1, "definition_digest": "<64 lowercase hex characters>" }` pins. Select `tools: [{ "type": "hosted_profile", "profile": "hosted-workspace-mcp/1" }]` and explicitly add that profile to `QWEN_MANAGED_AGENT_DEFINITION_TOOL_PROFILES`; an MCP profile without pins or pins with another profile are refused. The Runtime deployment owns the matching server manifest, endpoints, transport and credentials. `hook_catalog` similarly accepts `{ "catalog_id": "review", "catalog_revision": 1, "definition_digest": "<64 lowercase hex characters>" }` for any Workspace-bound tool profile, selecting a Runtime-provisioned Hook catalog. Revisions are positive JavaScript-safe integers. Neither field accepts executable recipes or credentials. Create/update continues to store future configuration shapes without interpreting them; the limits and supported shapes above are enforced at Session admission. An old definition containing an unsupported shape remains rejected there.
|
||||
|
||||
Roll out the compatible Runtime manifest, Broker and Hosted Harness before admitting configured Sessions. Set the Harness `QWEN_HOSTED_LIFECYCLE_V2_PROFILES` to include `hosted-workspace-mcp/1` before relying on MCP public close/delete, and preserve reliable Runtime stop support; configuration admission does not bypass these existing lifecycle gates. MCP/Hook load requests carry pins from the original admitted AgentDefinition revision, and Skills use the saved resource reference. Updating a definition affects new Sessions only. Unknown execution outcomes continue to require reconciliation of the original Runtime operation. Missing, corrupt or conflicting resources fail loading explicitly. Existing Sessions without extension fields keep their prior behavior for non-MCP profiles. The MCP profile requires nonempty server pins during create and attach/load validation. Older storage admission could retain an empty-pin MCP profile even though Hosted attachment already rejected it; audit stored definitions and Sessions before rollout rather than assuming none exist. Retain historical access separately and settle or close affected Sessions on the previous deployment before replacement. Publish a corrected definition revision for new Sessions; existing Session pins are not rewritten.
|
||||
+1
@@ -624,6 +624,7 @@ public final class ApiModels {
|
||||
@Size(max = 1000) List<@NotNull Map<String, Object>> skills,
|
||||
@JsonProperty("mcp_servers") @Size(max = 100)
|
||||
List<@NotNull Map<String, Object>> mcpServers,
|
||||
@JsonProperty("hook_catalog") Map<String, Object> hookCatalog,
|
||||
@JsonProperty("permission_policy") @NotNull
|
||||
Map<String, Object> permissionPolicy,
|
||||
@JsonProperty("environment_template_id") @Size(max = 128)
|
||||
|
||||
+42
@@ -16,6 +16,7 @@ import com.alibaba.qwen.code.daemon.SessionCreationOutcomeUnknownException;
|
||||
import com.alibaba.qwen.code.daemon.StreamHarnessEvents;
|
||||
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentDefinitionExtensions;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
|
||||
@@ -218,6 +219,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
false, toolProfile(session), false, false,
|
||||
WorkspaceToolProfiles.isPublicShell(toolProfile(session)))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withMcpServers(mcpServers(session))
|
||||
.withHookCatalog(hookCatalog(session))
|
||||
.withChildWorkspaces(childWorkspaces)
|
||||
.forLifecycle(operation.operationId(), operation.claimGeneration(), operation.lifecycleProtocolVersion() == 2 ? 2 : 1));
|
||||
attachments.put(key, ref);
|
||||
@@ -707,6 +710,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
false, false,
|
||||
WorkspaceToolProfiles.isPublicShell(toolProfile(session)))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withMcpServers(mcpServers(session))
|
||||
.withHookCatalog(hookCatalog(session))
|
||||
.withChildWorkspaces(childWorkspaces)
|
||||
.withStoppedMessages());
|
||||
if (session.workspace() != null && !actions.approvalMode(tenantId,
|
||||
@@ -936,6 +941,14 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
if (store != null) {
|
||||
builder.managedSessionStore(store);
|
||||
}
|
||||
List<Map<String, Object>> mcp = mcpServers(session);
|
||||
if (mcp != null) {
|
||||
builder.mcpServers(mcp);
|
||||
}
|
||||
Map<String, Object> hooks = hookCatalog(session);
|
||||
if (hooks != null) {
|
||||
builder.hookCatalog(hooks);
|
||||
}
|
||||
Map<String, Object> definition = agentDefinition(session, true);
|
||||
if (definition != null) {
|
||||
builder.agentDefinition(definition);
|
||||
@@ -980,6 +993,8 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
driveRuntimeRecovery, cancellationTakeover,
|
||||
WorkspaceToolProfiles.isPublicShell(profile))
|
||||
.withAgentDefinition(agentDefinition(session, false))
|
||||
.withMcpServers(mcpServers(session))
|
||||
.withHookCatalog(hookCatalog(session))
|
||||
.withChildWorkspaces(childWorkspaces));
|
||||
}
|
||||
|
||||
@@ -1177,9 +1192,36 @@ public class QwenHostedHarnessConnector implements HarnessConnector {
|
||||
"kind", ref.kind(), "schemaVersion", ref.schemaVersion(),
|
||||
"byteLength", ref.byteLength(), "digest", ref.digest()));
|
||||
}
|
||||
List<Map<String, Object>> skills = AgentDefinitionExtensions.skills(
|
||||
content.get("skills"), session.workspace() != null);
|
||||
if (!skills.isEmpty()) {
|
||||
String workspace = session.workspace().getWorkspaceId();
|
||||
var ref = resources.publishAgentSkills(session.tenantId(), workspace,
|
||||
session.sessionId(), skills);
|
||||
pin.put("skillsRef", Map.of("resourceId", ref.resourceId(),
|
||||
"kind", ref.kind(), "schemaVersion", ref.schemaVersion(),
|
||||
"byteLength", ref.byteLength(), "digest", ref.digest()));
|
||||
}
|
||||
return pin;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> mcpServers(SessionRecord session) {
|
||||
if (session.agentDefinitionDigest() == null || definitions == null) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, Object>> pins = AgentDefinitionExtensions.mcpServers(
|
||||
definitions.pinnedContent(session).get("mcp_servers"), session.toolProfile());
|
||||
return pins.isEmpty() ? null : pins;
|
||||
}
|
||||
|
||||
private Map<String, Object> hookCatalog(SessionRecord session) {
|
||||
if (session.agentDefinitionDigest() == null || definitions == null) {
|
||||
return null;
|
||||
}
|
||||
return AgentDefinitionExtensions.hookCatalog(
|
||||
definitions.pinnedContent(session).get("hook_catalog"), session.workspace() != null);
|
||||
}
|
||||
|
||||
private ManagedSessionStoreConnection managedSessionStore(
|
||||
SessionRecord session) {
|
||||
if (!sessionStore.isEnabled()) {
|
||||
|
||||
+3
-9
@@ -108,21 +108,15 @@ final class AgentDefinitionCompiler {
|
||||
toolProfile = profile;
|
||||
}
|
||||
}
|
||||
refuseNonEmptyList(definition.get("skills"), "skills");
|
||||
refuseNonEmptyList(definition.get("mcp_servers"), "mcp_servers");
|
||||
AgentDefinitionExtensions.skills(definition.get("skills"), toolProfile != null);
|
||||
AgentDefinitionExtensions.mcpServers(definition.get("mcp_servers"), toolProfile);
|
||||
AgentDefinitionExtensions.hookCatalog(definition.get("hook_catalog"), toolProfile != null);
|
||||
if (definition.get("environment_template_id") != null) {
|
||||
throw unsupported("environment_template_id");
|
||||
}
|
||||
return new Compiled(approvalMode, toolProfile);
|
||||
}
|
||||
|
||||
private static void refuseNonEmptyList(Object value, String field) {
|
||||
if (value != null && (!(value instanceof List<?> list)
|
||||
|| !list.isEmpty())) {
|
||||
throw unsupported(field);
|
||||
}
|
||||
}
|
||||
|
||||
private static ApiException unsupported(String field) {
|
||||
return new ApiException(HttpStatus.CONFLICT,
|
||||
"agent_definition_unsupported",
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The supported public extension shapes and their private Hosted pins. */
|
||||
public final class AgentDefinitionExtensions {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Pattern SKILL_NAME = Pattern.compile("^[\\p{L}\\p{N}_:.-]+$");
|
||||
private static final Pattern BLANK = Pattern.compile("^[\\s\\p{Zs}\\u2028\\u2029\\uFEFF]*$");
|
||||
private static final Pattern ID = Pattern.compile("^[A-Za-z0-9._:-]{1,128}$");
|
||||
private static final Pattern DIGEST = Pattern.compile("^[0-9a-f]{64}$");
|
||||
private static final Set<String> SKILL_FIELDS = Set.of("name", "description", "body", "base_directory");
|
||||
|
||||
private AgentDefinitionExtensions() {
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> skills(Object value, boolean workspaceBound) {
|
||||
List<?> entries = list(value, "skills");
|
||||
if (entries.size() > 16 || !entries.isEmpty() && !workspaceBound) {
|
||||
throw unsupported("skills");
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Object entry : entries) {
|
||||
if (!(entry instanceof Map<?, ?> skill)
|
||||
|| !SKILL_FIELDS.containsAll(skill.keySet())
|
||||
|| !text(skill.get("name"), 128)
|
||||
|| !SKILL_NAME.matcher((String) skill.get("name")).matches()
|
||||
|| !text(skill.get("description"), 4096)
|
||||
|| !text(skill.get("body"), 32768)
|
||||
|| !names.add((String) skill.get("name"))) {
|
||||
throw unsupported("skills");
|
||||
}
|
||||
Map<String, Object> supported = new LinkedHashMap<>();
|
||||
supported.put("name", skill.get("name"));
|
||||
supported.put("description", skill.get("description"));
|
||||
supported.put("body", skill.get("body"));
|
||||
if (skill.containsKey("base_directory")) {
|
||||
Object directory = skill.get("base_directory");
|
||||
if (!relativeDirectory(directory)) {
|
||||
throw unsupported("skills");
|
||||
}
|
||||
supported.put("base_directory", directory);
|
||||
}
|
||||
result.add(Collections.unmodifiableMap(supported));
|
||||
}
|
||||
skillsBytes(result);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public static byte[] skillsBytes(List<Map<String, Object>> skills) {
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = JSON.writeValueAsBytes(skills);
|
||||
} catch (JsonProcessingException error) {
|
||||
throw new IllegalStateException("Invalid admitted skills", error);
|
||||
}
|
||||
if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_SKILLS_BYTES) {
|
||||
throw unsupported("skills");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> mcpServers(Object value, String profile) {
|
||||
List<?> entries = list(value, "mcp_servers");
|
||||
if (entries.size() > 16 || entries.isEmpty() == WorkspaceToolProfiles.MCP.equals(profile)) {
|
||||
throw unsupported("mcp_servers");
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (Object entry : entries) {
|
||||
Map<String, Object> pin = pin(entry, "server", "mcp_servers");
|
||||
if (!ids.add((String) pin.get("serverId"))) {
|
||||
throw unsupported("mcp_servers");
|
||||
}
|
||||
result.add(pin);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public static Map<String, Object> hookCatalog(Object value, boolean workspaceBound) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!workspaceBound) {
|
||||
throw unsupported("hook_catalog");
|
||||
}
|
||||
return pin(value, "catalog", "hook_catalog");
|
||||
}
|
||||
|
||||
private static Map<String, Object> pin(Object value, String prefix, String field) {
|
||||
if (!(value instanceof Map<?, ?> pin)
|
||||
|| !pin.keySet().equals(Set.of(prefix + "_id", prefix + "_revision", "definition_digest"))
|
||||
|| !(pin.get(prefix + "_id") instanceof String id) || !ID.matcher(id).matches()
|
||||
|| !(pin.get(prefix + "_revision") instanceof Number revision)
|
||||
|| !Double.isFinite(revision.doubleValue()) || revision.doubleValue() != revision.longValue()
|
||||
|| revision.longValue() < 1 || revision.longValue() > 9007199254740991L
|
||||
|| !(pin.get("definition_digest") instanceof String digest) || !DIGEST.matcher(digest).matches()) {
|
||||
throw unsupported(field);
|
||||
}
|
||||
return Map.of(prefix + "Id", id, prefix + "Revision", revision.longValue(), "definitionDigest", digest);
|
||||
}
|
||||
|
||||
private static List<?> list(Object value, String field) {
|
||||
if (value == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (!(value instanceof List<?> entries)) {
|
||||
throw unsupported(field);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static boolean text(Object value, int maxBytes) {
|
||||
return value instanceof String text && !BLANK.matcher(text).matches()
|
||||
&& StandardCharsets.UTF_8.newEncoder().canEncode(text)
|
||||
&& text.getBytes(StandardCharsets.UTF_8).length <= maxBytes;
|
||||
}
|
||||
|
||||
private static boolean relativeDirectory(Object value) {
|
||||
if (!text(value, 4096)) {
|
||||
return false;
|
||||
}
|
||||
String directory = (String) value;
|
||||
if (".".equals(directory)) {
|
||||
return true;
|
||||
}
|
||||
if (directory.indexOf('\\') >= 0 || directory.indexOf(':') >= 0
|
||||
|| directory.codePoints().anyMatch(Character::isISOControl)) {
|
||||
return false;
|
||||
}
|
||||
for (String part : directory.split("/", -1)) {
|
||||
if (part.isEmpty() || part.equals(".") || part.equals("..")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ApiException unsupported(String field) {
|
||||
return new ApiException(HttpStatus.CONFLICT, "agent_definition_unsupported",
|
||||
"The agent definition field cannot take effect yet.", Map.of("field", field));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -149,7 +149,7 @@ public final class ManagedHostedRecoveryRecords {
|
||||
&& stored.schemaVersion() == node.get("schemaVersion").asInt()
|
||||
&& stored.byteLength() == node.get("byteLength").asLong()
|
||||
&& stored.digest().equals(node.get("digest").asText()), "Hosted nested resource conflicts.");
|
||||
if (KINDS.contains(stored.kind()) || List.of("managed-action-options", "managed-checkpoint",
|
||||
if (KINDS.contains(stored.kind()) || List.of("managed-action-options", "managed-checkpoint", "managed-definition",
|
||||
"managed-hook-plan", "managed-hook-message-chunks", "managed-message-chunks").contains(stored.kind())
|
||||
|| ManagedExtensionRecords.DOMAINS.stream().anyMatch(domain -> ("managed-" + domain).equals(stored.kind()))) {
|
||||
JsonNode body = ManagedExtensionRecordStore.parse(new String(stored.bytes(), StandardCharsets.UTF_8));
|
||||
|
||||
+28
-9
@@ -1503,8 +1503,23 @@ public class ManagedSessionStore {
|
||||
if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES) {
|
||||
throw payloadTooLarge("Agent instructions exceed 64 KiB.");
|
||||
}
|
||||
return publishAgentResource(tenantId, workspaceId, sessionId,
|
||||
"managed-agent-instructions", "agent-instructions-", bytes);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ToolResultResourceRef publishAgentSkills(String tenantId,
|
||||
String workspaceId, String sessionId, List<java.util.Map<String, Object>> skills) {
|
||||
validateScope(tenantId, workspaceId, sessionId);
|
||||
ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId);
|
||||
return publishAgentResource(tenantId, workspaceId, sessionId,
|
||||
"managed-agent-skills", "agent-skills-", AgentDefinitionExtensions.skillsBytes(skills));
|
||||
}
|
||||
|
||||
private ToolResultResourceRef publishAgentResource(String tenantId,
|
||||
String workspaceId, String sessionId, String kind, String prefix, byte[] bytes) {
|
||||
String digest = sha256(bytes);
|
||||
String resourceId = "agent-instructions-" + digest;
|
||||
String resourceId = prefix + digest;
|
||||
String scopeKey = sessionScopeKey(tenantId, sessionId);
|
||||
Timestamp now = databaseNow();
|
||||
ResourceRow existing = findResource(scopeKey, resourceId);
|
||||
@@ -1514,14 +1529,14 @@ public class ManagedSessionStore {
|
||||
+ " workspace_id, session_id, resource_id, kind, schema_version,"
|
||||
+ " byte_length, sha256, storage_kind, inline_bytes, publish_command_id,"
|
||||
+ " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?,"
|
||||
+ " 'managed-agent-instructions', 1, ?, ?, 'MYSQL_INLINE', ?, ?,"
|
||||
+ " 'PUBLISHED', ?, ?)",
|
||||
+ " ?, 1, ?, ?, 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)",
|
||||
resourceKey(scopeKey, resourceId),
|
||||
scopeKey,
|
||||
tenantId,
|
||||
workspaceId,
|
||||
sessionId,
|
||||
resourceId,
|
||||
kind,
|
||||
bytes.length,
|
||||
digest,
|
||||
bytes,
|
||||
@@ -1531,16 +1546,15 @@ public class ManagedSessionStore {
|
||||
} else {
|
||||
requireResourceScope(existing, tenantId, workspaceId, sessionId, resourceId);
|
||||
verifyStoredResource(existing);
|
||||
if (!"managed-agent-instructions".equals(existing.kind())
|
||||
if (!kind.equals(existing.kind())
|
||||
|| existing.schemaVersion() != 1
|
||||
|| existing.byteLength() != bytes.length
|
||||
|| !digest.equals(existing.digest())) {
|
||||
throw conflict("managed_session_resource_conflict",
|
||||
"Agent instructions resource does not match.");
|
||||
"Agent configuration resource does not match.");
|
||||
}
|
||||
}
|
||||
return new ToolResultResourceRef(resourceId,
|
||||
"managed-agent-instructions", 1, bytes.length, digest);
|
||||
return new ToolResultResourceRef(resourceId, kind, 1, bytes.length, digest);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -1647,7 +1661,9 @@ public class ManagedSessionStore {
|
||||
}
|
||||
requireResourceScope(resource, tenantId, workspaceId, sessionId,
|
||||
resourceId);
|
||||
if (!"REFERENCED".equals(resource.state())) {
|
||||
if (!"REFERENCED".equals(resource.state())
|
||||
&& !("PUBLISHED".equals(resource.state())
|
||||
&& Set.of("managed-agent-instructions", "managed-agent-skills").contains(resource.kind()))) {
|
||||
throw conflict(ManagedSessionStoreModels.ERROR_RESOURCE_MISSING,
|
||||
"A referenced Managed Session resource is missing.");
|
||||
}
|
||||
@@ -2454,7 +2470,10 @@ public class ManagedSessionStore {
|
||||
&& (resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind())
|
||||
|| "managed-agent-instructions".equals(resource.kind())
|
||||
&& resource.byteLength() >= 0
|
||||
&& resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES))
|
||||
&& resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES
|
||||
|| "managed-agent-skills".equals(resource.kind())
|
||||
&& resource.byteLength() > 0
|
||||
&& resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_SKILLS_BYTES))
|
||||
|| resource.bytes() == null
|
||||
|| resource.objectKey() != null
|
||||
|| resource.objectVersionId() != null
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ public final class ManagedSessionStoreModels {
|
||||
/** The agent-instructions bound the publisher and both verifiers
|
||||
* must agree on (admission, publish, verify, recovery read). */
|
||||
public static final int MAX_AGENT_INSTRUCTIONS_BYTES = 64 * 1024;
|
||||
public static final int MAX_AGENT_SKILLS_BYTES = 48 * 1024;
|
||||
public static final int MAX_RESOURCES_PER_TRANSACTION = 1024;
|
||||
public static final int MAX_TRANSACTION_BYTES = 8 * 1024 * 1024;
|
||||
public static final int MAX_TRANSACTION_EVENTS = 256;
|
||||
|
||||
+6
-3
@@ -84,11 +84,14 @@ final class WorkspaceRecoveryReader {
|
||||
boolean published = "PUBLISHED".equals(row.get("state"))
|
||||
&& (Set.of("managed-tool-result-content", "managed-tool-result-manifest",
|
||||
"managed-tool-result-page").contains(text(ref, "kind"))
|
||||
// The digest-addressed instructions resource never
|
||||
// commits; it stays PUBLISHED for its whole life.
|
||||
// Digest-addressed agent configuration resources never
|
||||
// commit; they stay PUBLISHED for their whole life.
|
||||
|| "managed-agent-instructions".equals(text(ref, "kind"))
|
||||
&& number(row, "byte_length") >= 0
|
||||
&& number(row, "byte_length") <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES);
|
||||
&& number(row, "byte_length") <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES
|
||||
|| "managed-agent-skills".equals(text(ref, "kind"))
|
||||
&& number(row, "byte_length") > 0
|
||||
&& number(row, "byte_length") <= ManagedSessionStoreModels.MAX_AGENT_SKILLS_BYTES);
|
||||
check(("REFERENCED".equals(row.get("state")) || published) && row.get("object_key") == null
|
||||
&& row.get("object_version_id") == null && row.get("encryption_key_id") == null,
|
||||
"resource_layout_unsupported");
|
||||
|
||||
+85
-4
@@ -2,8 +2,8 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Qwen Managed Agent Public and WebShell API",
|
||||
"version": "1.47.0",
|
||||
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
|
||||
"version": "1.48.0",
|
||||
"description": "Canonical contract for the public Managed Agent resources and the WebShell adapter. partial means that the route exists but generated types and full contract tests are pending; planned means that the route or field is not implemented. Workspace context and cwd operations are v1.9 target contracts, not current server capabilities. v1.10 adds planned command admission, Actions, lifecycle and actor authorization. v1.12 refines planned W0 creation discovery, actor-specific create hints and explicit default selection. Filter planned fields as well as routes from production SDKs. v1.13 moves this contract into the Qwen Code repository as its single source and records the shipped rename, archive, unarchive and delete routes as partial. v1.14 integrates W0d workspace discovery, the WebShell workspace lookup, and empty-session binding as partial; workspace_binding/workspaceBinding advertises this narrow flow without enabling workspace execution or context switching. v1.15 adds the archived, archiving and deleting Session statuses and the cancelling Turn status, requires request_id in error envelopes, declares the error responses that the server returns, and marks Session create, list and get on both surfaces implemented. v1.16 adds the planned Stage H task contract (SessionTaskView as PublicTask, task list, detail, events and cancel) and names the MCP catalog, hook catalog, automation and channel resources as planned; their shapes arrive with Stages H1 to H6. v1.17 implements event replay: events carry their schema and projection versions and a top-level Item and Part identity, JSON event pages return has_more and next_cursor and accept limits up to 1000, and a persisted replay floor answers expired cursors with 409 cursor_expired in JSON and one agent.session.resync_required frame over SSE; it marks the public event query and stream, the WebShell event stream and the WebShell transcript implemented. v1.18 implements the durable Session lifecycle (Stage D4): close, archive and delete answer 202 with a command operation on both surfaces, an archive requires a closed Session, a deleted Session leaves a tombstone whose operations stay readable, and the operation query serves them; unarchive restores a closed Session, and Session capabilities advertise session_lifecycle. v1.19 serves the task list and detail on both surfaces as partial (Stage H0c): the Session store projects them from the Stage H records the Session authority commits, a task.updated Session event announces each change of a task's view, and Session capabilities advertise tasks. Task events and cancel stay planned until a slice's tasks produce output and accept a cancel. v1.20 implements the Turn read model (Stage D5): the public Turn list and detail read a Session's Turns without their input, newest first by creation time in milliseconds and then Turn ID, with an opaque cursor; a deleted Session's Turns are not readable, and the durable admission fields of a Turn stay planned until D7. v1.21 declares on the task list, detail and event routes of both surfaces the 403 actor_scope_mismatch that the tenant filter answers on every /v1/agents/ and WebShell route, as the Session and Turn reads already do, and the shared Forbidden response names it; a caller that cannot read a task still gets 404. v1.22 adds deployment-opted-in creation with an initial Workspace file-tool Turn (G0) on the public and WebShell surfaces; the fixed server-owned profile does not enable later Workspace operations. v1.23 settles the planned task event and cancel semantics: a durable retention floor, committed-prefix publication, stable cursors, Artifact visibility before expiry, bounded backlog under archival failure, ordered idempotent cancellation and command outcomes. Clients tolerate unknown optional event fields across minor versions. Task events and cancel remain planned. v1.24 serves the read-only Session MCP catalog as partial (Stage H1), projecting display metadata and schemas from committed records without Runtime identities, credentials or production profile enablement. v1.25 implements permission Actions (Stage D6b) on both surfaces: list, get and respond, projected from the Session's action.changed records; a WebShell permission Action carries inputRevision, policyRevision, functionCallId, toolName and expiresAt, its option ids allow and deny are stable, a Turn has at most one requested approval at a time, and a response is a durable command operation carrying requestId. v1.26 declares the tenant filter's 403 actor_scope_mismatch on every covered public and WebShell route, including planned routes, and pins the refusal code in contract traffic. v1.27 adds O3 durable tool-result projection, artifact metadata, immutable range/streaming content and WebShell result reads. Artifact capability requires a configured O3 reader and a Workspace-bound Session; execution admission remains independent. v1.28 admits later Turns of a Workspace-bound Session for the SLine truncated
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -4869,7 +4869,7 @@
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"description": "null is the same as omitting the field.",
|
||||
"description": "Stored revision content remains open to future options. Null or [] is the same as omitting the field. At Session admission only AgentStaticSkill shapes are supported, at most 16 with unique names and a Workspace tool profile; the compact UTF-8 JSON resource must not exceed 49152 bytes. Skill permissions, hooks, model overrides and other fields are refused at admission.",
|
||||
"type": ["array", "null"],
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
@@ -4878,7 +4878,7 @@
|
||||
}
|
||||
},
|
||||
"mcp_servers": {
|
||||
"description": "null is the same as omitting the field.",
|
||||
"description": "Stored revision content remains open to future options. At Session admission only AgentMcpServerPin shapes are supported: 1-16 unique Runtime-provisioned server pins with no recipes or credentials, requiring explicitly allowlisted hosted-workspace-mcp/1. That profile requires nonempty pins; null or [] preserves behavior for other profiles. Compatible lifecycle v2 capability is required for public close.",
|
||||
"type": ["array", "null"],
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
@@ -4886,6 +4886,11 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"hook_catalog": {
|
||||
"description": "Stored revision content remains open to future options. At Session admission only the AgentHookCatalogPin shape is supported: a Runtime-provisioned catalog identity, requiring a Workspace-bound tool profile, without recipes or credentials. Null is the same as omitting the field.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"permission_policy": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
@@ -9847,6 +9852,82 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"AgentStaticSkill": {
|
||||
"description": "The supported static Skill shape at Session admission; stored revisions may contain future options but those options cannot execute.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description", "body"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[\\p{L}\\p{N}_:.-]+$",
|
||||
"description": "At most 128 UTF-8 bytes; duplicate names are refused at Session admission."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"description": "Nonblank text, at most 4096 UTF-8 bytes."
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 32768,
|
||||
"description": "Nonblank Markdown, at most 32768 UTF-8 bytes. Loaded verbatim; no permission, Hook, or model side effects."
|
||||
},
|
||||
"base_directory": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"default": ".",
|
||||
"description": "A canonical POSIX directory relative to the Session working directory, at most 4096 UTF-8 bytes, matching Runtime read_file and glob semantics. The default is '.'. Absolute paths, empty/dot/traversal components, colons, backslashes, control characters, and invalid Unicode are refused at Session admission."
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentMcpServerPin": {
|
||||
"description": "The supported Runtime definition pin shape at Session admission.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["server_id", "server_revision", "definition_digest"],
|
||||
"properties": {
|
||||
"server_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9._:-]{1,128}$"
|
||||
},
|
||||
"server_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"definition_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgentHookCatalogPin": {
|
||||
"description": "The supported Runtime definition pin shape at Session admission.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["catalog_id", "catalog_revision", "definition_digest"],
|
||||
"properties": {
|
||||
"catalog_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9._:-]{1,128}$"
|
||||
},
|
||||
"catalog_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"definition_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -1790,6 +1790,14 @@ class ManagedAgentApiContractTest {
|
||||
"permission_policy":{"mode":"default"},
|
||||
"metadata":{"team":"contract"}}
|
||||
""";
|
||||
ObjectNode configured = (ObjectNode) objectMapper.readTree(body);
|
||||
configured.putArray("skills").addObject().put("name", "评审:review").put("description", "Review changes")
|
||||
.put("body", "Read the diff.").put("base_directory", ".qwen/skills/review");
|
||||
configured.putArray("mcp_servers").addObject().put("server_id", "docs").put("server_revision", 1)
|
||||
.put("definition_digest", "a".repeat(64));
|
||||
configured.putObject("hook_catalog").put("catalog_id", "review").put("catalog_revision", 2)
|
||||
.put("definition_digest", "b".repeat(64));
|
||||
body = configured.toString();
|
||||
JsonNode created = json(exchange(drift, "createAgent", 202,
|
||||
post("/v1/agents").header(TENANT, tenant)
|
||||
.header(IDEMPOTENCY_KEY, "agent-create"), body));
|
||||
|
||||
+27
@@ -184,6 +184,33 @@ class ManagedAgentDefinitionAdmissionTest {
|
||||
Integer.class, tenant)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredExtensionsStayOnTheAdmittedRevisionAfterAnUpdate() throws Exception {
|
||||
String tenant = tenant();
|
||||
register(tenant, "ws-a", "storage-a");
|
||||
grant(tenant, "ws-a", "actor", true);
|
||||
var content = mapper.readTree(FILES_DEFINITION).deepCopy();
|
||||
var object = (com.fasterxml.jackson.databind.node.ObjectNode) content;
|
||||
object.putArray("skills").addObject().put("name", "review").put("description", "Review changes")
|
||||
.put("body", "Original instructions").put("base_directory", ".qwen/skills/review");
|
||||
object.putObject("hook_catalog").put("catalog_id", "review").put("catalog_revision", 1)
|
||||
.put("definition_digest", "a".repeat(64));
|
||||
JsonNode definition = define(tenant, "create", object.toString());
|
||||
String agentId = definition.path("id").asText();
|
||||
JsonNode created = mapper.readTree(createBound(tenant, agentId, null, "configured")
|
||||
.andExpect(status().isAccepted()).andReturn().getResponse().getContentAsString());
|
||||
String sessionId = created.path("id").asText();
|
||||
var session = store.requireSession(tenant, sessionId);
|
||||
assertThat(definitions.pinnedContent(session)).containsKey("skills").containsKey("hook_catalog");
|
||||
define(tenant, "update", agentId, object.toString().replace("Original instructions", "New instructions"));
|
||||
assertThat(mapper.valueToTree(definitions.pinnedContent(session)).at("/skills/0/body").asText())
|
||||
.isEqualTo("Original instructions");
|
||||
createBound(tenant, agentId, null, "configured").andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.id").value(sessionId)).andExpect(jsonPath("$.agent_revision").value("1"));
|
||||
createBound(tenant, agentId, null, "new-configured").andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.agent_revision").value("2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void omittedAndExplicitRevisionsPinAndExposeTheDigest() throws Exception {
|
||||
String tenant = tenant();
|
||||
|
||||
+3
-1
@@ -143,7 +143,7 @@ class ManagedAgentDefinitionTest {
|
||||
JsonNode absent = json(create(tenant, "absent", minimum.toString())
|
||||
.andExpect(status().isAccepted()));
|
||||
ObjectNode withNulls = minimum.deepCopy();
|
||||
for (String field : new String[] {"skills", "mcp_servers",
|
||||
for (String field : new String[] {"skills", "mcp_servers", "hook_catalog",
|
||||
"environment_template_id", "metadata"}) {
|
||||
withNulls.putNull(field);
|
||||
}
|
||||
@@ -167,6 +167,8 @@ class ManagedAgentDefinitionTest {
|
||||
ObjectNode content = (ObjectNode) objectMapper.readTree(DEFINITION);
|
||||
content.putArray("skills").addObject().put("name", "review");
|
||||
content.putArray("mcp_servers").addObject().put("name", "docs");
|
||||
content.putObject("hook_catalog").put("catalog_id", "review").put("catalog_revision", 1)
|
||||
.put("definition_digest", "b".repeat(64));
|
||||
content.put("environment_template_id", "template-1");
|
||||
JsonNode first = json(create(tenant, "optional", content.toString())
|
||||
.andExpect(status().isAccepted()));
|
||||
|
||||
+33
@@ -102,6 +102,7 @@ class ManagedAgentMySqlIT {
|
||||
List.of(Map.of("name", "read_file")),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Map.of("mode", "default"),
|
||||
null,
|
||||
null);
|
||||
@@ -2032,6 +2033,7 @@ class ManagedAgentMySqlIT {
|
||||
DEFINITION.tools(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DEFINITION.permissionPolicy(),
|
||||
null,
|
||||
null);
|
||||
@@ -2042,6 +2044,7 @@ class ManagedAgentMySqlIT {
|
||||
DEFINITION.tools(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DEFINITION.permissionPolicy(),
|
||||
null,
|
||||
null);
|
||||
@@ -2219,6 +2222,36 @@ class ManagedAgentMySqlIT {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesAndReadsStaticSkillResourcesAcrossStoreInstances() throws IOException {
|
||||
DriverManagerDataSource source = dataSource();
|
||||
Flyway.configure().dataSource(source).locations("classpath:db/managed-schema").load().migrate();
|
||||
JdbcTemplate jdbc = new com.alibaba.qwen.code.managedagent.store.ManagedSchemaJdbcTemplate(source);
|
||||
TransactionTemplate transactions = new TransactionTemplate(new DataSourceTransactionManager(source));
|
||||
ManagedSessionStore resources = new ManagedSessionStore(jdbc);
|
||||
String tenant = "mysql-skills-" + UUID.randomUUID();
|
||||
String session = UUID.randomUUID().toString();
|
||||
String token = "s".repeat(32);
|
||||
inTransaction(transactions, () -> resources.acquireWriter(tenant, session, token,
|
||||
new AcquireWriterRequest("workspace", "writer", 60000L)));
|
||||
List<Map<String, Object>> skills = List.of(Map.of("name", "评审:review", "description", "Review changes",
|
||||
"body", "Read the original diff.\n", "base_directory", ".qwen/skills/review"));
|
||||
var published = inTransaction(transactions,
|
||||
() -> resources.publishAgentSkills(tenant, "workspace", session, skills));
|
||||
assertThat(inTransaction(transactions,
|
||||
() -> resources.publishAgentSkills(tenant, "workspace", session, skills))).isEqualTo(published);
|
||||
var reloaded = new ManagedSessionStore(new com.alibaba.qwen.code.managedagent.store.ManagedSchemaJdbcTemplate(source))
|
||||
.readResource(tenant, "workspace", session, published.resourceId(), token);
|
||||
assertThat(new ObjectMapper().readTree(reloaded.bytes())).isEqualTo(new ObjectMapper().valueToTree(skills));
|
||||
assertThat(reloaded.digest()).isEqualTo(published.digest());
|
||||
assertThat(reloaded.kind()).isEqualTo("managed-agent-skills");
|
||||
jdbc.update("UPDATE ma_resource SET inline_bytes = ? WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
|
||||
"[]".getBytes(StandardCharsets.UTF_8), tenant, session, published.resourceId());
|
||||
assertThatThrownBy(() -> resources.readResource(tenant, "workspace", session, published.resourceId(), token))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("managed_session_resource_corrupt"));
|
||||
}
|
||||
|
||||
private static boolean isWindows() {
|
||||
return System.getProperty("os.name", "").toLowerCase().contains("win");
|
||||
}
|
||||
|
||||
+102
@@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore.TaskRow;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels;
|
||||
import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector;
|
||||
import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository;
|
||||
@@ -13,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint;
|
||||
@@ -24,6 +26,8 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
@@ -31,7 +35,9 @@ import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -79,6 +85,102 @@ class ManagedSessionStoreIntegrationTest {
|
||||
@Autowired
|
||||
private ManagedExtensionRecordStore records;
|
||||
|
||||
@Autowired
|
||||
private ManagedSessionStore sessionStore;
|
||||
|
||||
@Test
|
||||
void acceptsClientBuiltCensusAfterTheDefinitionIsCommitted(@TempDir Path directory) throws Exception {
|
||||
ArrayNode fixtures = objectMapper.createArrayNode();
|
||||
for (String mode : List.of("none", "instructions", "skills", "both")) {
|
||||
String session = "definition-census-" + UUID.randomUUID();
|
||||
String base = "/internal/managed-session-store/v1/sessions/" + session;
|
||||
mvc.perform(post(base + "/writers:acquire").header(TenantContextFilter.HEADER, TENANT)
|
||||
.header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, TOKEN_A)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(writerRequest(WRITER_A).toString()))
|
||||
.andExpect(status().isOk());
|
||||
ObjectNode fixture = fixtures.addObject();
|
||||
fixture.putObject("sessionKey").put("tenantId", TENANT).put("workspaceId", WORKSPACE).put("sessionId", session);
|
||||
ObjectNode nested = fixture.putObject("nested");
|
||||
ArrayNode published = fixture.putArray("published");
|
||||
List<ManagedSessionStoreModels.ToolResultResourceRef> pins = new ArrayList<>();
|
||||
if (mode.equals("instructions") || mode.equals("both")) {
|
||||
var ref = sessionStore.publishAgentInstructions(TENANT, WORKSPACE, session, "Pinned instructions.");
|
||||
nested.set("instructionsRef", objectMapper.valueToTree(ref));
|
||||
pins.add(ref);
|
||||
}
|
||||
if (mode.equals("skills") || mode.equals("both")) {
|
||||
var ref = sessionStore.publishAgentSkills(TENANT, WORKSPACE, session,
|
||||
List.of(Map.of("name", "review", "description", "Review", "body", "Read the diff.")));
|
||||
nested.set("skillsRef", objectMapper.valueToTree(ref));
|
||||
pins.add(ref);
|
||||
}
|
||||
for (var ref : pins) {
|
||||
var resource = sessionStore.readResource(TENANT, WORKSPACE, session, ref.resourceId(), TOKEN_A);
|
||||
published.addObject().put("resourceId", ref.resourceId())
|
||||
.put("bytesBase64", Base64.getEncoder().encodeToString(resource.bytes()));
|
||||
}
|
||||
}
|
||||
Path input = directory.resolve("input.json");
|
||||
Path output = directory.resolve("output.json");
|
||||
Path script = directory.resolve("census.mjs");
|
||||
Path log = directory.resolve("node.log");
|
||||
objectMapper.writeValue(input.toFile(), fixtures);
|
||||
try (var stream = getClass().getResourceAsStream("/hosted-definition-census.mjs")) {
|
||||
assertThat(stream).isNotNull();
|
||||
Files.copy(stream, script);
|
||||
}
|
||||
Process process = new ProcessBuilder(System.getProperty("node.executable", "node"),
|
||||
script.toString(), input.toString(), output.toString()).directory(Path.of("../..").toRealPath().toFile())
|
||||
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
|
||||
try {
|
||||
assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("client census generator finishes").isTrue();
|
||||
assertThat(process.exitValue()).as(Files.readString(log)).isZero();
|
||||
} finally {
|
||||
if (process.isAlive()) {
|
||||
process.destroyForcibly().waitFor(10, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
for (JsonNode fixture : objectMapper.readTree(output.toFile())) {
|
||||
String base = "/internal/managed-session-store/v1/sessions/" + fixture.path("sessionKey").path("sessionId").asText();
|
||||
for (String stage : List.of("create", "later")) {
|
||||
ObjectNode commit = ((ObjectNode) fixture.required(stage)).deepCopy()
|
||||
.put("workspaceId", WORKSPACE).put("writerId", WRITER_A).put("writerGeneration", 1)
|
||||
.put("expectedJournalRevision", stage.equals("create") ? 0 : 1).put("expectedCommittedSequence", 0);
|
||||
MvcResult result = mvc.perform(post(base + "/transactions:commit-v2").header(TenantContextFilter.HEADER, TENANT)
|
||||
.header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, TOKEN_A)
|
||||
.header("X-Qwen-Managed-Max-Readable-Storage-Version", "2")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(commit.toString())).andReturn();
|
||||
assertThat(result.getResponse().getStatus()).as(result.getResponse().getContentAsString()).isEqualTo(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesStaticSkillsAsImmutableSessionScopedResources() throws Exception {
|
||||
String session = "agent-skills-" + UUID.randomUUID();
|
||||
sessionStore.acquireWriter(TENANT, session, TOKEN_A,
|
||||
new ManagedSessionStoreModels.AcquireWriterRequest(WORKSPACE, WRITER_A, 60000L));
|
||||
List<Map<String, Object>> skills = List.of(Map.of("name", "review", "description", "Review changes",
|
||||
"body", "Read the diff.\n", "base_directory", ".qwen/skills/review"));
|
||||
var published = sessionStore.publishAgentSkills(TENANT, WORKSPACE, session, skills);
|
||||
var replayed = sessionStore.publishAgentSkills(TENANT, WORKSPACE, session, skills);
|
||||
assertThat(replayed).isEqualTo(published);
|
||||
assertThat(published.kind()).isEqualTo("managed-agent-skills");
|
||||
var resource = sessionStore.readResource(TENANT, WORKSPACE, session, published.resourceId(), TOKEN_A);
|
||||
assertThat(objectMapper.readTree(resource.bytes())).isEqualTo(objectMapper.valueToTree(skills));
|
||||
assertThat(resource.digest()).isEqualTo(published.digest());
|
||||
assertThat(resource.byteLength()).isEqualTo(published.byteLength());
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> sessionStore.readResource(TENANT,
|
||||
"other-workspace", session, published.resourceId(), TOKEN_A)).isInstanceOf(RuntimeException.class);
|
||||
jdbc.update("UPDATE ma_resource SET inline_bytes = ? WHERE tenant_id = ? AND session_id = ? AND resource_id = ?",
|
||||
"[]".getBytes(StandardCharsets.UTF_8), TENANT, session, published.resourceId());
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> sessionStore.readResource(TENANT,
|
||||
WORKSPACE, session, published.resourceId(), TOKEN_A)).isInstanceOfSatisfying(com.alibaba.qwen.code.managedagent.api.ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("managed_session_resource_corrupt"));
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> sessionStore.publishAgentSkills(TENANT,
|
||||
WORKSPACE, session, skills)).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void promotesAtomicallyAndRefusesOlderHttpReadersIncludingReplay() throws Exception {
|
||||
String session = "g3-reader-" + UUID.randomUUID();
|
||||
|
||||
+47
@@ -16,6 +16,9 @@ import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedActionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentDefinitionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.ToolResultResourceRef;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationKind;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.OperationRecord;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord;
|
||||
@@ -2672,6 +2675,50 @@ class QwenHostedHarnessConnectorTest {
|
||||
verify(client, never()).loadSession(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsPinnedExtensionsAndPublishesSkillsOnlyOnCreate() {
|
||||
SessionRecord session = new SessionRecord("tenant-a", SESSION_ID,
|
||||
"agent_00112233445566778899aabbccddeeff", "2", null, "ACTIVE",
|
||||
null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
new ContextBinding("tenant-a", "workspace-a", 1, "storage", ".", "config", 1),
|
||||
"default", "hosted-workspace-mcp/1", "a".repeat(64));
|
||||
List<Map<String, Object>> skills = List.of(Map.of("name", "review", "description", "Review", "body", "Read the diff."));
|
||||
ManagedAgentDefinitionStore definitions = mock(ManagedAgentDefinitionStore.class);
|
||||
when(definitions.pinnedContent(session)).thenReturn(Map.of("skills", skills,
|
||||
"mcp_servers", List.of(Map.of("server_id", "docs", "server_revision", 1, "definition_digest", "b".repeat(64))),
|
||||
"hook_catalog", Map.of("catalog_id", "review", "catalog_revision", 2, "definition_digest", "c".repeat(64))));
|
||||
ManagedSessionStore resources = mock(ManagedSessionStore.class);
|
||||
when(resources.publishAgentSkills("tenant-a", "workspace-a", SESSION_ID, skills))
|
||||
.thenReturn(new ToolResultResourceRef("skills", "managed-agent-skills", 1, 100, "d".repeat(64)));
|
||||
ManagedActionStore actions = mock(ManagedActionStore.class);
|
||||
when(actions.approvalMode("tenant-a", SESSION_ID)).thenReturn("default");
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
HostedHarnessCapabilities capabilities = mock(HostedHarnessCapabilities.class);
|
||||
when(client.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.getBootId()).thenReturn(BOOT_ID);
|
||||
QwenHostedHarnessConnector connector = new QwenHostedHarnessConnector(properties(), sessions(),
|
||||
mock(WorkspaceExecutionStore.class), actions, definitions, resources);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
ReflectionTestUtils.invokeMethod(connector, "create", session, false);
|
||||
ReflectionTestUtils.invokeMethod(connector, "load", session, false);
|
||||
ArgumentCaptor<CreateHarnessSession> creates = ArgumentCaptor.forClass(CreateHarnessSession.class);
|
||||
verify(client).createSession(creates.capture());
|
||||
ArgumentCaptor<LoadHarnessSession> loads = ArgumentCaptor.forClass(LoadHarnessSession.class);
|
||||
verify(client).loadSession(loads.capture());
|
||||
Map<String, Object> created = ReflectionTestUtils.invokeMethod(creates.getValue(), "toJson");
|
||||
Map<String, Object> loaded = ReflectionTestUtils.invokeMethod(loads.getValue(), "toJson");
|
||||
Map<String, Object> identity = Map.of("agentId", session.agentId(), "revision", "2", "digest", "a".repeat(64));
|
||||
assertThat(loaded).containsEntry("agentDefinition", identity);
|
||||
assertThat(created).containsEntry("mcpServers", List.of(Map.of("serverId", "docs", "serverRevision", 1L,
|
||||
"definitionDigest", "b".repeat(64)))).containsEntry("hookCatalog", Map.of("catalogId", "review",
|
||||
"catalogRevision", 2L, "definitionDigest", "c".repeat(64)));
|
||||
assertThat(loaded).containsEntry("mcpServers", created.get("mcpServers"))
|
||||
.containsEntry("hookCatalog", created.get("hookCatalog"));
|
||||
assertThat(new ObjectMapper().valueToTree(created).at("/agentDefinition/skillsRef/kind").asText())
|
||||
.isEqualTo("managed-agent-skills");
|
||||
verify(resources, times(1)).publishAgentSkills("tenant-a", "workspace-a", SESSION_ID, skills);
|
||||
}
|
||||
|
||||
private static ManagedAgentProperties properties() {
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setToken("token");
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class ManagedAgentDefinitionServiceTest {
|
||||
private static final AgentDefinitionRequest REQUEST =
|
||||
new AgentDefinitionRequest(Map.of("id", "test-model"),
|
||||
"Review the change.", List.of(Map.of("name", "read_file")),
|
||||
null, null, Map.of("mode", "default"), null, null);
|
||||
null, null, null, Map.of("mode", "default"), null, null);
|
||||
private static final DefinitionRevision WINNER = new DefinitionRevision(
|
||||
AGENT_ID, 1, "a".repeat(64), "{\"metadata\":{\"team\":\"x\"}}", 7);
|
||||
private static final Map<String, Object> CONTENT = Map.of(
|
||||
|
||||
+17
@@ -180,4 +180,21 @@ class AgentDefinitionCompilerTest {
|
||||
assertThat(error.getDetails().get("field"))
|
||||
.isEqualTo("model"));
|
||||
}
|
||||
@Test
|
||||
void extensionsCompileOnlyWithTheirCompatibleAllowlistedProfile() {
|
||||
String pins = ",\"skills\":[{\"name\":\"review\",\"description\":\"Review\",\"body\":\"Read the diff.\"}],"
|
||||
+ "\"mcp_servers\":[{\"server_id\":\"docs\",\"server_revision\":1,\"definition_digest\":\""
|
||||
+ "a".repeat(64) + "\"}],\"hook_catalog\":{\"catalog_id\":\"review\",\"catalog_revision\":1,"
|
||||
+ "\"definition_digest\":\"" + "b".repeat(64) + "\"}}";
|
||||
String base = definition(hostedProfile(WorkspaceToolProfiles.MCP), "{\"approval_mode\":\"default\"}");
|
||||
String configured = base.substring(0, base.length() - 1) + pins;
|
||||
assertThat(AgentDefinitionCompiler.compile(configured, true, "yolo", List.of(WorkspaceToolProfiles.MCP))
|
||||
.toolProfile()).isEqualTo(WorkspaceToolProfiles.MCP);
|
||||
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(configured, true, "yolo", FILES_ONLY))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getDetails()).containsEntry("field", "tools"));
|
||||
assertThatThrownBy(() -> AgentDefinitionCompiler.compile(base, true, "yolo", List.of(WorkspaceToolProfiles.MCP)))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getDetails()).containsEntry("field", "mcp_servers"));
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class AgentDefinitionExtensionsTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Map<String, Object> SKILL = Map.of(
|
||||
"name", "评审:review", "description", "Review changes", "body", "Read the diff.");
|
||||
private static final Map<String, Object> MCP = Map.of(
|
||||
"server_id", "docs", "server_revision", 1, "definition_digest", "a".repeat(64));
|
||||
|
||||
@Test
|
||||
void staticSkillsKeepTheirOriginalBytesAndNeverApplySideEffectOptions() throws Exception {
|
||||
Map<String, Object> skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put("body", " Read the diff.\n");
|
||||
skill.put("base_directory", ".qwen/skills/review");
|
||||
List<Map<String, Object>> parsed = AgentDefinitionExtensions.skills(List.of(skill), true);
|
||||
assertThat(JSON.readTree(AgentDefinitionExtensions.skillsBytes(parsed)))
|
||||
.isEqualTo(JSON.valueToTree(List.of(skill)));
|
||||
assertThat(AgentDefinitionExtensions.skills(null, false)).isEmpty();
|
||||
assertThat(AgentDefinitionExtensions.skills(List.of(), false)).isEmpty();
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(SKILL), false));
|
||||
for (String field : List.of("allowed_tools", "allowedTools", "hooks", "model", "extra")) {
|
||||
skill.put(field, "unsupported");
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(skill), true));
|
||||
skill.remove(field);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticSkillLimitsUseUtf8AndValidateTheWholeCatalog() {
|
||||
Map<String, Object> skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put("name", "界".repeat(42));
|
||||
assertThat(AgentDefinitionExtensions.skills(List.of(skill), true)).hasSize(1);
|
||||
skill.put("name", "界".repeat(43));
|
||||
Map<String, Object> oversizedName = skill;
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(oversizedName), true));
|
||||
for (var limit : Map.of("description", 4096, "body", 32768).entrySet()) {
|
||||
skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put(limit.getKey(), "a".repeat(limit.getValue()));
|
||||
assertThat(AgentDefinitionExtensions.skills(List.of(skill), true)).hasSize(1);
|
||||
skill.put(limit.getKey(), "a".repeat(limit.getValue()) + "界");
|
||||
Map<String, Object> oversized = skill;
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(oversized), true));
|
||||
}
|
||||
List<Map<String, Object>> catalog = new ArrayList<>();
|
||||
for (int i = 0; i < 16; i++) {
|
||||
catalog.add(Map.of("name", "skill-" + i, "description", "d", "body", "b"));
|
||||
}
|
||||
assertThat(AgentDefinitionExtensions.skills(catalog, true)).hasSize(16);
|
||||
catalog.add(Map.of("name", "overflow", "description", "d", "body", "b"));
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(catalog, true));
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(SKILL, SKILL), true));
|
||||
List<Map<String, Object>> oversizedCatalog = List.of(
|
||||
Map.of("name", "a", "description", "d", "body", "x".repeat(25000)),
|
||||
Map.of("name", "b", "description", "d", "body", "x".repeat(25000)));
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(oversizedCatalog, true));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", "bad name", "bad/name", "bad\nname", "\uD800"})
|
||||
void refusesInvalidSkillNames(String name) {
|
||||
Map<String, Object> skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put("name", name);
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(skill), true));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", "\t\n", "\uD800"})
|
||||
void refusesBlankOrInvalidUnicodeSkillText(String text) {
|
||||
for (String field : List.of("description", "body")) {
|
||||
Map<String, Object> skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put(field, text);
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(skill), true));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "/tmp", "../secret", "a/../b", "a/./b", "./a", "a//b", "a/", "a\\b",
|
||||
"C:/tmp", "file:tmp", "a\nfile", "a\u0085file", "\uD800"})
|
||||
void refusesAmbiguousOrEscapingSkillDirectories(String directory) {
|
||||
Map<String, Object> skill = new LinkedHashMap<>(SKILL);
|
||||
skill.put("base_directory", directory);
|
||||
assertRefused("skills", () -> AgentDefinitionExtensions.skills(List.of(skill), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mcpPinsRequireTheMcpProfileAndPreserveSafeIntegerRevisions() {
|
||||
assertThat(AgentDefinitionExtensions.mcpServers(List.of(MCP), WorkspaceToolProfiles.MCP))
|
||||
.containsExactly(Map.of("serverId", "docs", "serverRevision", 1L,
|
||||
"definitionDigest", "a".repeat(64)));
|
||||
assertThat(AgentDefinitionExtensions.mcpServers(null, WorkspaceToolProfiles.FILES)).isEmpty();
|
||||
assertRefused("mcp_servers", () -> AgentDefinitionExtensions.mcpServers(null, WorkspaceToolProfiles.MCP));
|
||||
assertRefused("mcp_servers", () -> AgentDefinitionExtensions.mcpServers(List.of(MCP), WorkspaceToolProfiles.FILES));
|
||||
assertRefused("mcp_servers", () -> AgentDefinitionExtensions.mcpServers(List.of(MCP, MCP), WorkspaceToolProfiles.MCP));
|
||||
for (Object revision : List.of(0, -1, 1.5, "1", 9007199254740992L)) {
|
||||
Map<String, Object> pin = new LinkedHashMap<>(MCP);
|
||||
pin.put("server_revision", revision);
|
||||
assertRefused("mcp_servers", () -> AgentDefinitionExtensions.mcpServers(List.of(pin), WorkspaceToolProfiles.MCP));
|
||||
}
|
||||
Map<String, Object> pin = new LinkedHashMap<>(MCP);
|
||||
pin.put("server_revision", 9007199254740991L);
|
||||
assertThat(AgentDefinitionExtensions.mcpServers(List.of(pin), WorkspaceToolProfiles.MCP)
|
||||
.getFirst().get("serverRevision")).isEqualTo(9007199254740991L);
|
||||
pin.put("command", "forbidden");
|
||||
assertRefused("mcp_servers", () -> AgentDefinitionExtensions.mcpServers(List.of(pin), WorkspaceToolProfiles.MCP));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hookCatalogsRequireWorkspaceAndOnlyCarryTheDefinitionIdentity() {
|
||||
Map<String, Object> pin = new LinkedHashMap<>(Map.of("catalog_id", "review", "catalog_revision", 2,
|
||||
"definition_digest", "b".repeat(64)));
|
||||
assertThat(AgentDefinitionExtensions.hookCatalog(pin, true))
|
||||
.isEqualTo(Map.of("catalogId", "review", "catalogRevision", 2L, "definitionDigest", "b".repeat(64)));
|
||||
assertThat(AgentDefinitionExtensions.hookCatalog(null, false)).isNull();
|
||||
assertRefused("hook_catalog", () -> AgentDefinitionExtensions.hookCatalog(pin, false));
|
||||
pin.put("headers", Map.of("Authorization", "forbidden"));
|
||||
assertRefused("hook_catalog", () -> AgentDefinitionExtensions.hookCatalog(pin, true));
|
||||
}
|
||||
|
||||
private static void assertRefused(String field, Runnable operation) {
|
||||
assertThatThrownBy(operation::run).isInstanceOfSatisfying(ApiException.class, error -> {
|
||||
assertThat(error.getStatus().value()).isEqualTo(409);
|
||||
assertThat(error.getCode()).isEqualTo("agent_definition_unsupported");
|
||||
assertThat(error.getDetails()).containsEntry("field", field);
|
||||
});
|
||||
}
|
||||
}
|
||||
+37
@@ -100,6 +100,43 @@ class ManagedHostedRecoveryRecordsTest {
|
||||
org.assertj.core.api.Assertions.assertThat(reads.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesAgentSkillReferencesInsideTheManagedDefinitionClosure() {
|
||||
byte[] bytes = "[{\"name\":\"review\",\"description\":\"Review\",\"body\":\"Read the diff.\"}]"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
var skills = new ManagedSessionStoreModels.StoredResource("skills", "managed-agent-skills", 1,
|
||||
bytes.length, ToolPublicationContract.sha256(bytes), bytes);
|
||||
ObjectNode agent = json.createObjectNode();
|
||||
agent.set("skillsRef", reference(skills));
|
||||
ObjectNode body = json.createObjectNode();
|
||||
body.set("agentDefinition", agent);
|
||||
var definition = stored("definition", "managed-definition", body);
|
||||
var ref = reference(definition);
|
||||
java.util.function.Function<String, ManagedSessionStoreModels.StoredResource> reader = id -> {
|
||||
if (id.equals(definition.resourceId())) {
|
||||
return definition;
|
||||
}
|
||||
if (id.equals(skills.resourceId())) {
|
||||
return skills;
|
||||
}
|
||||
throw new IllegalStateException("Missing nested resource");
|
||||
};
|
||||
assertThatCode(() -> ManagedHostedRecoveryRecords.reference(ref, "managed-definition",
|
||||
"tenant", "workspace", "session", reader)).doesNotThrowAnyException();
|
||||
assertThatThrownBy(() -> ManagedHostedRecoveryRecords.reference(ref, "managed-definition",
|
||||
"tenant", "workspace", "session", id -> {
|
||||
if (id.equals(definition.resourceId())) {
|
||||
return definition;
|
||||
}
|
||||
throw new IllegalStateException("Missing skills");
|
||||
})).hasMessageContaining("Missing skills");
|
||||
assertThatThrownBy(() -> ManagedHostedRecoveryRecords.reference(ref, "managed-definition",
|
||||
"tenant", "workspace", "session", id -> id.equals(definition.resourceId()) ? definition
|
||||
: new ManagedSessionStoreModels.StoredResource("skills", skills.kind(), 1,
|
||||
skills.byteLength(), "0".repeat(64), bytes)))
|
||||
.hasMessageContaining("Hosted nested resource conflicts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDigestCleanupIdentityForTheMaximumStablePrompt() {
|
||||
String prompt = "a".repeat(512);
|
||||
|
||||
+5
@@ -52,6 +52,11 @@ class WorkspaceRecoveryMySqlIT {
|
||||
fixture.setUp();
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesAndVerifiesSessionsThatPinStoredDefinitions() throws Exception {
|
||||
fixture.capturesAndVerifiesSessionsThatPinStoredDefinitions();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryAdmissionIgnoresOtherOperationKindsButBlocksUnresolvedSessionEffects() {
|
||||
fixture.recoveryAdmissionIgnoresOtherOperationKindsButBlocksUnresolvedSessionEffects();
|
||||
|
||||
+23
-16
@@ -426,6 +426,10 @@ class WorkspaceRecoveryStoreTest {
|
||||
+ " WHERE session_id = ?", pinned);
|
||||
var instructions = new ManagedSessionStore(jdbc).publishAgentInstructions(
|
||||
"tenant", "workspace-b", pinned, "Keep the agent instructions. 保留指令。");
|
||||
var skillCatalog = List.<Map<String, Object>>of(Map.of(
|
||||
"name", "review", "description", "Review", "body", "Check the diff. 检查差异。"));
|
||||
var skills = new ManagedSessionStore(jdbc).publishAgentSkills(
|
||||
"tenant", "workspace-b", pinned, skillCatalog);
|
||||
JsonNode sources = call(capture(), "sessions").path("sessions");
|
||||
assertThat(sources).hasSize(3);
|
||||
JsonNode pinnedSource = null;
|
||||
@@ -453,25 +457,28 @@ class WorkspaceRecoveryStoreTest {
|
||||
.isEqualTo("1");
|
||||
assertThat(builtinSource.path("agentDefinitionDigest").isNull())
|
||||
.isTrue();
|
||||
// The recovery read of the PUBLISHED instructions resource must
|
||||
// The recovery read of the PUBLISHED agent resources must
|
||||
// return the published bytes (resource_layout_unsupported before
|
||||
// the reader accepted the kind).
|
||||
var capture = capture();
|
||||
ObjectNode ref = object().put("resourceId", instructions.resourceId())
|
||||
.put("kind", instructions.kind())
|
||||
.put("schemaVersion", instructions.schemaVersion())
|
||||
.put("byteLength", (int) instructions.byteLength())
|
||||
.put("digest", instructions.digest());
|
||||
ObjectNode read = object().put("sessionId", pinned);
|
||||
read.set("ref", ref);
|
||||
assertThat(capture.call("resource", read).path("bytesBase64").asText())
|
||||
.isEqualTo(java.util.Base64.getEncoder().encodeToString(
|
||||
"Keep the agent instructions. 保留指令。"
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
capture.call("enqueueRef", read);
|
||||
assertThat(capture.call("nextRef", object().put("sessionId", pinned)).toString())
|
||||
.isEqualTo(ref.toString());
|
||||
capture.call("completeRef", read);
|
||||
for (var resource : List.of(instructions, skills)) {
|
||||
ObjectNode ref = object().put("resourceId", resource.resourceId())
|
||||
.put("kind", resource.kind())
|
||||
.put("schemaVersion", resource.schemaVersion())
|
||||
.put("byteLength", (int) resource.byteLength())
|
||||
.put("digest", resource.digest());
|
||||
ObjectNode read = object().put("sessionId", pinned);
|
||||
read.set("ref", ref);
|
||||
byte[] expected = resource == instructions
|
||||
? "Keep the agent instructions. 保留指令。".getBytes(StandardCharsets.UTF_8)
|
||||
: AgentDefinitionExtensions.skillsBytes(skillCatalog);
|
||||
assertThat(capture.call("resource", read).path("bytesBase64").asText())
|
||||
.isEqualTo(java.util.Base64.getEncoder().encodeToString(expected));
|
||||
capture.call("enqueueRef", read);
|
||||
assertThat(capture.call("nextRef", object().put("sessionId", pinned)).toString())
|
||||
.isEqualTo(ref.toString());
|
||||
capture.call("completeRef", read);
|
||||
}
|
||||
assertThat(capture.inspect().path("pendingReferences").asLong())
|
||||
.isZero();
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import process from 'node:process';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
const { createHttpManagedSessionStores, describeTransaction } = await import(
|
||||
pathToFileURL(
|
||||
`${process.cwd()}/packages/core/dist/src/managed-runtime/http-managed-session-store.js`,
|
||||
).href
|
||||
);
|
||||
const { managedSessionEventsDigest } = await import(
|
||||
pathToFileURL(
|
||||
`${process.cwd()}/packages/core/dist/src/managed-runtime/managed-session-records.js`,
|
||||
).href
|
||||
);
|
||||
const { LocalManagedSessionAuthority } = await import(
|
||||
pathToFileURL(
|
||||
`${process.cwd()}/packages/core/dist/src/managed-runtime/managed-session-authority.js`,
|
||||
).href
|
||||
);
|
||||
const { SessionWriterLease } = await import(
|
||||
pathToFileURL(
|
||||
`${process.cwd()}/packages/core/dist/src/services/session-writer-lease.js`,
|
||||
).href
|
||||
);
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const fixtures = JSON.parse(await readFile(process.argv[2], 'utf8'));
|
||||
for (const fixture of fixtures) {
|
||||
const { sessionKey, nested, published } = fixture;
|
||||
const stores = createHttpManagedSessionStores({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
sessionKey,
|
||||
writerId: 'writer-a',
|
||||
});
|
||||
const resources = stores.resourceStore;
|
||||
const bytes = new Map(
|
||||
published.map((entry) => [
|
||||
entry.resourceId,
|
||||
Buffer.from(entry.bytesBase64, 'base64'),
|
||||
]),
|
||||
);
|
||||
resources.bind({ readResource: async (ref) => bytes.get(ref.resourceId) });
|
||||
const publish = async (kind, value) => {
|
||||
const content = Buffer.from(JSON.stringify(value));
|
||||
const ref = await resources.publish(kind, content);
|
||||
bytes.set(ref.resourceId, content);
|
||||
return ref;
|
||||
};
|
||||
const definitionRef = await publish('managed-definition', {
|
||||
engine: 'managed',
|
||||
...(Object.keys(nested).length
|
||||
? {
|
||||
agentDefinition: {
|
||||
agentId: `agent_${'a'.repeat(32)}`,
|
||||
revision: '1',
|
||||
digest: 'b'.repeat(64),
|
||||
...nested,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const rootSnapshotRef = await publish('managed-root', { cwd: '/workspace' });
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'definition-census-'));
|
||||
const transcriptPath = path.join(directory, 'session.jsonl');
|
||||
const lease = await SessionWriterLease.acquire({
|
||||
runtimeBaseDir: directory,
|
||||
sessionId: sessionKey.sessionId,
|
||||
transcriptPath,
|
||||
});
|
||||
try {
|
||||
await LocalManagedSessionAuthority.open({
|
||||
lease,
|
||||
sessionKey,
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
create: { definitionRef, rootSnapshotRef, createdBy: 'test' },
|
||||
});
|
||||
const content = await readFile(transcriptPath);
|
||||
const records = content
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
const { refs: _refs, ...transaction } = describeTransaction(
|
||||
records,
|
||||
content,
|
||||
0,
|
||||
sessionKey,
|
||||
);
|
||||
fixture.create = {
|
||||
...transaction,
|
||||
recordCount: records.length,
|
||||
recordBytesBase64: content.toString('base64'),
|
||||
recordDigest: hash(content),
|
||||
};
|
||||
} finally {
|
||||
await lease.release();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
fixture.create.resources = await resources.commitResources([
|
||||
definitionRef,
|
||||
rootSnapshotRef,
|
||||
]);
|
||||
resources.releaseCommitted(fixture.create.resources);
|
||||
const prepared = {
|
||||
request: { model: 'fixture', contents: [], config: {} },
|
||||
history: [],
|
||||
completedToolCallIds: [],
|
||||
routeSelector: 'openai:fixture',
|
||||
providerPin: 'a'.repeat(64),
|
||||
promptTokensForClamp: 0,
|
||||
};
|
||||
const recoveryRef = await publish('hosted-model-request', {
|
||||
v: 1,
|
||||
sessionKey,
|
||||
promptId: 'prompt',
|
||||
definitionRef,
|
||||
rootSnapshotRef,
|
||||
sourceActivation: { activationId: 'activation', epoch: 1 },
|
||||
sourceBootId: 'boot',
|
||||
sourceEventEpoch: 'epoch',
|
||||
messageId: 'model-message',
|
||||
round: 0,
|
||||
throughSequence: 1,
|
||||
pendingToolResults: false,
|
||||
workspaceContext: null,
|
||||
prepared,
|
||||
requestDigest: hash(Buffer.from(JSON.stringify(prepared))),
|
||||
});
|
||||
const event = {
|
||||
v: 1,
|
||||
sequence: 1,
|
||||
eventId: 'model-event',
|
||||
sessionKey,
|
||||
kind: 'model.attempt',
|
||||
occurredAt: 1,
|
||||
subject: {
|
||||
type: 'activation',
|
||||
scopeId: 'activation',
|
||||
activationId: 'activation',
|
||||
epoch: 1,
|
||||
},
|
||||
payload: {
|
||||
attemptId: 'prompt:main:0',
|
||||
state: 'started',
|
||||
routeRef: rootSnapshotRef,
|
||||
inputCheckpointRef: null,
|
||||
usageRef: null,
|
||||
recoveryRef,
|
||||
},
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
subtype: 'managed_session_event_v1',
|
||||
sessionId: sessionKey.sessionId,
|
||||
managedSession: event,
|
||||
},
|
||||
{
|
||||
subtype: 'managed_session_commit_v1',
|
||||
sessionId: sessionKey.sessionId,
|
||||
managedSession: {
|
||||
transactionId: 'later',
|
||||
commandId: 'later',
|
||||
operation: 'model.attempt',
|
||||
contentDigest: 'a'.repeat(64),
|
||||
firstSequence: 1,
|
||||
lastSequence: 1,
|
||||
eventCount: 1,
|
||||
eventsDigest: managedSessionEventsDigest([event]),
|
||||
previousCommitDigest: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const recordBytes = Buffer.from(
|
||||
records.map((record) => JSON.stringify(record)).join('\n') + '\n',
|
||||
);
|
||||
const { refs: _refs, ...transaction } = describeTransaction(
|
||||
records,
|
||||
recordBytes,
|
||||
0,
|
||||
sessionKey,
|
||||
);
|
||||
fixture.later = {
|
||||
...transaction,
|
||||
recordCount: records.length,
|
||||
recordBytesBase64: recordBytes.toString('base64'),
|
||||
recordDigest: hash(recordBytes),
|
||||
resources: await resources.commitResources([recoveryRef]),
|
||||
};
|
||||
}
|
||||
await writeFile(process.argv[3], JSON.stringify(fixtures));
|
||||
+24
@@ -1,7 +1,9 @@
|
||||
package com.alibaba.qwen.code.daemon;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Input for creating a Hosted Harness session with a caller-owned UUID. */
|
||||
public final class CreateHarnessSession {
|
||||
@@ -12,6 +14,8 @@ public final class CreateHarnessSession {
|
||||
private final Long approvalTimeoutMs;
|
||||
private final Long captureBytes;
|
||||
private final Map<String, Object> agentDefinition;
|
||||
private final List<Map<String, Object>> mcpServers;
|
||||
private final Map<String, Object> hookCatalog;
|
||||
private final Map<String, Object> lineage;
|
||||
private final boolean suppressChildAgents;
|
||||
private final boolean childWorkspaces;
|
||||
@@ -25,6 +29,8 @@ public final class CreateHarnessSession {
|
||||
this.approvalTimeoutMs = builder.approvalTimeoutMs;
|
||||
this.captureBytes = builder.captureBytes;
|
||||
this.agentDefinition = builder.agentDefinition;
|
||||
this.mcpServers = builder.mcpServers;
|
||||
this.hookCatalog = builder.hookCatalog;
|
||||
this.lineage = builder.lineage;
|
||||
this.suppressChildAgents = builder.suppressChildAgents;
|
||||
this.childWorkspaces = builder.childWorkspaces;
|
||||
@@ -63,6 +69,12 @@ public final class CreateHarnessSession {
|
||||
if (agentDefinition != null) {
|
||||
result.put("agentDefinition", agentDefinition);
|
||||
}
|
||||
if (mcpServers != null) {
|
||||
result.put("mcpServers", mcpServers);
|
||||
}
|
||||
if (hookCatalog != null) {
|
||||
result.put("hookCatalog", hookCatalog);
|
||||
}
|
||||
if (lineage != null) {
|
||||
result.put("lineage", lineage);
|
||||
}
|
||||
@@ -80,6 +92,8 @@ public final class CreateHarnessSession {
|
||||
private Long approvalTimeoutMs;
|
||||
private Long captureBytes;
|
||||
private Map<String, Object> agentDefinition;
|
||||
private List<Map<String, Object>> mcpServers;
|
||||
private Map<String, Object> hookCatalog;
|
||||
private Map<String, Object> lineage;
|
||||
private boolean suppressChildAgents;
|
||||
private boolean childWorkspaces;
|
||||
@@ -100,6 +114,16 @@ public final class CreateHarnessSession {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder mcpServers(List<Map<String, Object>> value) {
|
||||
this.mcpServers = value.stream().map(Map::copyOf).collect(Collectors.toUnmodifiableList());
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder hookCatalog(Map<String, Object> value) {
|
||||
this.hookCatalog = Map.copyOf(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder approvalTimeoutMs(long value) {
|
||||
if (value < 1000 || value > 86400000) {
|
||||
throw new IllegalArgumentException("Invalid approval timeout");
|
||||
|
||||
+26
@@ -1,7 +1,9 @@
|
||||
package com.alibaba.qwen.code.daemon;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Input for attaching Java to an existing Hosted Harness session. */
|
||||
public final class LoadHarnessSession {
|
||||
@@ -15,6 +17,8 @@ public final class LoadHarnessSession {
|
||||
private Map<String, Object> lifecycleAuthority;
|
||||
private int lifecycleProtocolVersion = 1;
|
||||
private Map<String, Object> agentDefinition;
|
||||
private List<Map<String, Object>> mcpServers;
|
||||
private Map<String, Object> hookCatalog;
|
||||
private boolean stopMessages;
|
||||
|
||||
/**
|
||||
@@ -28,11 +32,23 @@ public final class LoadHarnessSession {
|
||||
return this;
|
||||
}
|
||||
|
||||
public LoadHarnessSession withMcpServers(List<Map<String, Object>> value) {
|
||||
this.mcpServers = value == null ? null : value.stream().map(Map::copyOf).collect(Collectors.toUnmodifiableList());
|
||||
return this;
|
||||
}
|
||||
|
||||
public LoadHarnessSession withHookCatalog(Map<String, Object> value) {
|
||||
this.hookCatalog = value == null ? null : Map.copyOf(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LoadHarnessSession withChildWorkspaces(boolean value) {
|
||||
LoadHarnessSession copy = new LoadHarnessSession(harnessSessionId, managedSessionStore,
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.mcpServers = mcpServers;
|
||||
copy.hookCatalog = hookCatalog;
|
||||
copy.lifecycleAuthority = lifecycleAuthority;
|
||||
copy.lifecycleProtocolVersion = lifecycleProtocolVersion;
|
||||
copy.childWorkspaces = value;
|
||||
@@ -55,6 +71,8 @@ public final class LoadHarnessSession {
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.mcpServers = mcpServers;
|
||||
copy.hookCatalog = hookCatalog;
|
||||
copy.lifecycleAuthority = Map.of("operationId", operationId, "claimGeneration", claimGeneration);
|
||||
copy.lifecycleProtocolVersion = protocolVersion;
|
||||
copy.childWorkspaces = childWorkspaces;
|
||||
@@ -69,6 +87,8 @@ public final class LoadHarnessSession {
|
||||
passiveManagedRuntimeRecovery, toolProfile, driveRuntimeRecovery, cancellationTakeover,
|
||||
suppressChildAgents);
|
||||
copy.agentDefinition = agentDefinition;
|
||||
copy.mcpServers = mcpServers;
|
||||
copy.hookCatalog = hookCatalog;
|
||||
copy.lifecycleAuthority = lifecycleAuthority;
|
||||
copy.lifecycleProtocolVersion = lifecycleProtocolVersion;
|
||||
copy.childWorkspaces = childWorkspaces;
|
||||
@@ -172,6 +192,12 @@ public final class LoadHarnessSession {
|
||||
if (agentDefinition != null) {
|
||||
result.put("agentDefinition", agentDefinition);
|
||||
}
|
||||
if (mcpServers != null) {
|
||||
result.put("mcpServers", mcpServers);
|
||||
}
|
||||
if (hookCatalog != null) {
|
||||
result.put("hookCatalog", hookCatalog);
|
||||
}
|
||||
if (stopMessages) {
|
||||
result.put("stopMessages", true);
|
||||
}
|
||||
|
||||
+23
-2
@@ -16,20 +16,20 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
class HostedHarnessClientTest {
|
||||
private static final String BOOT_ID =
|
||||
@@ -757,6 +757,27 @@ class HostedHarnessClientTest {
|
||||
assertEquals(lifecycle.get("lifecycleAuthority"), reversed.get("lifecycleAuthority"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extensionPinsSurviveCreateAndEveryLoadCopy() {
|
||||
var mcp = java.util.List.<Map<String, Object>>of(Map.of("serverId", "docs", "serverRevision", 1,
|
||||
"definitionDigest", "a".repeat(64)));
|
||||
Map<String, Object> hooks = Map.of("catalogId", "review", "catalogRevision", 1,
|
||||
"definitionDigest", "b".repeat(64));
|
||||
Map<String, Object> created = CreateHarnessSession.builder().harnessSessionId(SESSION_ID)
|
||||
.mcpServers(mcp).hookCatalog(hooks).build().toJson();
|
||||
assertEquals(mcp, created.get("mcpServers"));
|
||||
assertEquals(hooks, created.get("hookCatalog"));
|
||||
LoadHarnessSession load = new LoadHarnessSession(SESSION_ID).withMcpServers(mcp).withHookCatalog(hooks);
|
||||
for (LoadHarnessSession copy : java.util.List.of(load, load.withChildWorkspaces(true),
|
||||
load.forLifecycle("op-1", 3, 2), load.withStoppedMessages(),
|
||||
load.withStoppedMessages().forLifecycle("op-1", 3, 2).withChildWorkspaces(true))) {
|
||||
assertEquals(mcp, copy.toJson().get("mcpServers"));
|
||||
assertEquals(hooks, copy.toJson().get("hookCatalog"));
|
||||
}
|
||||
assertFalse(new LoadHarnessSession(SESSION_ID).toJson().containsKey("mcpServers"));
|
||||
assertFalse(CreateHarnessSession.builder().harnessSessionId(SESSION_ID).build().toJson().containsKey("hookCatalog"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesAnAgentWaitRuntimeRecovery() {
|
||||
server.createContext("/session/" + SESSION_ID + "/load",
|
||||
|
||||
Reference in new issue
Block a user