From 46f6adebbb3e358f3adc321f48382c5cabd9e101 Mon Sep 17 00:00:00 2001 From: Lancy Date: Thu, 22 Oct 2026 15:09:42 +0800 Subject: [PATCH] fix(composition): make lightweight image capability optional --- desktop/frontend-next/src/port/composition.ts | 2 +- .../src/port/mock_composition.test.ts | 29 +++++ .../src/port/mock_composition.ts | 13 +- docs/AGENT_COMPOSITIONS.md | 18 ++- docs/AGENT_COMPOSITIONS.zh.md | 13 +- internal/assembly/boot/composition.go | 3 + .../assembly/boot/effect_view_image_test.go | 28 +++-- .../agentcomposition/builtin-revisions.json | 2 +- .../contract/agentcomposition/builtins.json | 2 +- .../contract/agentcomposition/composition.go | 6 +- .../agentcomposition/composition_test.go | 111 +++++++++++++++++- internal/frontend/serve/agent_presets.go | 3 + internal/frontend/serve/agent_presets_test.go | 35 ++++++ 13 files changed, 238 insertions(+), 27 deletions(-) diff --git a/desktop/frontend-next/src/port/composition.ts b/desktop/frontend-next/src/port/composition.ts index 5033a65453..d3e19ca741 100644 --- a/desktop/frontend-next/src/port/composition.ts +++ b/desktop/frontend-next/src/port/composition.ts @@ -1,7 +1,7 @@ import definitions from "../../../../internal/contract/agentcomposition/builtins.json"; export type CompositionKind = "tools" | "skills" | "mcp" | "extensions" | "hooks" | "commands"; -export interface CompositionModule { id: string; parameters?: Record } +export interface CompositionModule { id: string; parameters?: Record; optional?: boolean } // A module the editor offers: the id is what a preset stores; the description is what the module // says about itself, for the person choosing, and is never saved. export interface CompositionChoice { id: string; description?: string } diff --git a/desktop/frontend-next/src/port/mock_composition.test.ts b/desktop/frontend-next/src/port/mock_composition.test.ts index 95a1629239..da9a87d479 100644 --- a/desktop/frontend-next/src/port/mock_composition.test.ts +++ b/desktop/frontend-next/src/port/mock_composition.test.ts @@ -50,3 +50,32 @@ describe("project composition authority", () => { await expect(presets.selectAgentComposition("user:gone")).rejects.toMatchObject({reason:{code:"agent_composition.unavailable",params:{module:"tools:no_such_tool"}}}); }); }); + +describe("optional tool dependencies", () => { + it("admits missing optional tools without relaxing required tool selection", async () => { + const presets = new MockCompositions(); + const definition = {...builtinCompositions()[0],id:"user:optional",tools:{mode:"select" as const,members:[{id:"bash"},{id:"missing_image",optional:true}]}}; + const saved = await presets.saveAgentPreset(definition,""); + await expect(presets.selectAgentComposition(definition.id)).resolves.toMatchObject({id:definition.id}); + await presets.saveAgentPreset({...definition,tools:{...definition.tools,members:[...definition.tools.members,{id:"missing_required"}]}},saved.revision); + await expect(presets.selectAgentComposition(definition.id)).rejects.toMatchObject({reason:{code:"agent_composition.unavailable",params:{module:"tools:missing_required"}}}); + }); + + it.each([undefined, false, true])("reports missing imported tools unless optional is true (%s)", async (optional) => { + const presets = new MockCompositions(); + const definition = {...builtinCompositions()[0],id:"user:import",tools:{mode:"select" as const,members:[{id:"missing_image",optional}]}}; + const preview = await presets.importAgentPreset(definition); + if (optional) expect(preview.diagnostics).toEqual([]); + else { + expect(preview.diagnostics).toHaveLength(1); + expect(preview.diagnostics[0]).toMatchObject({code:"agent_composition.unavailable",module:"tools:missing_image"}); + } + }); + + it.each(["skills","mcp","extensions","hooks","commands"] as const)("rejects optional %s members on save and import", async (kind) => { + const presets = new MockCompositions(); + const definition = {...builtinCompositions()[0],id:"user:import",[kind]:{mode:"select" as const,members:[{id:"missing_dependency",optional:true}]}}; + await expect(presets.saveAgentPreset(definition,"")).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}}); + await expect(presets.importAgentPreset(definition)).rejects.toMatchObject({reason:{code:"agent_composition.invalid"}}); + }); +}); diff --git a/desktop/frontend-next/src/port/mock_composition.ts b/desktop/frontend-next/src/port/mock_composition.ts index 0b13b6bb3e..bd408a6955 100644 --- a/desktop/frontend-next/src/port/mock_composition.ts +++ b/desktop/frontend-next/src/port/mock_composition.ts @@ -34,12 +34,15 @@ export class MockCompositions { private fail(status:number,code:string):never { throw new HttpError(status,"",{code:`agent_composition.${code}`}); } private requireModules(definition:AgentComposition) { for (const kind of ["tools"] as const) for (const member of definition[kind]?.members ?? []) { - if (definition[kind]?.mode==="select" && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) { + if (definition[kind]?.mode==="select" && !member.optional && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) { throw new HttpError(409,"",{code:"agent_composition.unavailable",params:{module:`${kind}:${member.id}`,detail:"Required module is unavailable or was revoked."}}); } } } - private validateAuthority(definition:AgentComposition) { + private validateDefinition(definition:AgentComposition) { + for (const kind of ["skills","mcp","extensions","hooks","commands"] as const) { + if (definition[kind]?.members.some((member)=>member.optional)) this.fail(400,"invalid"); + } if (!definition.id.startsWith("project:")) return; for (const kind of ["mcp","extensions"] as const) for (const member of definition[kind]?.members ?? []) { if (Object.entries(member.parameters ?? {}).some(([key,value])=>["args","url","env","headers"].includes(key.toLowerCase()) && value!=null)) this.fail(400,"invalid"); @@ -51,7 +54,7 @@ export class MockCompositions { const prior=this.catalog.presets.find((v)=>v.definition.id===definition.id); if ((prior?.revision ?? "")!==expectedRevision) this.fail(409,"conflict"); if (!/^(user|project):[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(definition.id) || !definition.name.trim()) this.fail(400,"invalid"); - this.validateAuthority(definition); + this.validateDefinition(definition); const version={definition:structuredClone(definition),revision:`mock-${++this.library.revision}`}; const items=definition.id.startsWith("project:") ? this.library.projects.get(this.workspace) ?? [] : this.library.user; const next=[...items.filter((v)=>v.definition.id!==definition.id),version]; @@ -77,10 +80,10 @@ export class MockCompositions { } async importAgentPreset(content:AgentComposition) { if (content.schemaVersion!==1 || !content.name || !content.persona) this.fail(400,"invalid"); - this.validateAuthority(content); + this.validateDefinition(content); const diagnostics:AgentPresetCatalog["diagnostics"]=[]; for (const kind of ["tools","skills","mcp","extensions","hooks","commands"] as const) for (const member of content[kind]?.members ?? []) { - if (!(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) diagnostics.push({code:"agent_composition.unavailable",module:`${kind}:${member.id}`,detail:"Dependency is not available on this host."}); + if (!(kind==="tools" && member.optional) && !(MOCK_MODULES?.[kind] ?? []).some((m)=>m.id===member.id)) diagnostics.push({code:"agent_composition.unavailable",module:`${kind}:${member.id}`,detail:"Dependency is not available on this host."}); } return {definition:structuredClone(content),diagnostics}; } diff --git a/docs/AGENT_COMPOSITIONS.md b/docs/AGENT_COMPOSITIONS.md index 2bd5764c24..ce3a8601a3 100644 --- a/docs/AGENT_COMPOSITIONS.md +++ b/docs/AGENT_COMPOSITIONS.md @@ -60,10 +60,10 @@ editable rows. Usage, details and copy do not change the new-task default. | Mode | Behavior | | --- | --- | | Everyday partner (`builtin:general`) | Inherit current configuration for everyday code, file and research work. The stable ID preserves existing defaults. | -| Lightweight partner (`builtin:minimal`) | A concise system persona, terminal (`bash`) and image reading (`view_image`), with native context compaction. Requires a vision-capable model. | +| Lightweight partner (`builtin:minimal`) | A concise system persona and terminal (`bash`), with native context compaction. Adds image reading (`view_image`) when the selected model reads images and the tool is enabled. | -These display names replace Standard and Minimal; their IDs and capabilities -stay unchanged. Retained conversations keep their saved names and revisions. +The stable IDs preserve existing defaults. Retained conversations keep their +saved names, revisions and module requirements. The persona reference is DeepSeek Harness commit `687f282e8625c6b8d11ff58211a90524be2e4317`: @@ -85,8 +85,9 @@ It retains the host controls needed to answer and ask questions. Its terminal can still modify files and access the network under existing permissions and sandbox rules; Lightweight partner is not a read-only mode. -Missing image capability or -disabled tools produce an explicit dependency error rather than another tool set. +Missing required tools produce an explicit dependency error. Lightweight partner +requires `bash`; its `view_image` member is optional, so a text-only model or a +configuration that disables image reading can still use the terminal. Code review and Research are no longer built-in catalog entries. Their existing conversations retain the original binding. An old default pointing to a retired @@ -96,6 +97,13 @@ Copy a built-in to customize it. Each module group either follows current author configuration, including resources enabled later, or uses only the selected IDs. An empty explicit selection disables the group. +A selected tool member may declare `"optional": true`, for example +`{"id":"view_image","optional":true}`. It is admitted only if already available; +the declaration does not enable a disabled tool or grant image capability. + +Omitting `optional` keeps the member required. Other module groups do not accept +optional members. + The editor lists locally available modules; imports preview missing dependencies without installing or launching them. diff --git a/docs/AGENT_COMPOSITIONS.zh.md b/docs/AGENT_COMPOSITIONS.zh.md index 80101f34b5..f2ec78464d 100644 --- a/docs/AGENT_COMPOSITIONS.zh.md +++ b/docs/AGENT_COMPOSITIONS.zh.md @@ -40,10 +40,9 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook | 模式 | 行为 | | --- | --- | | 日常搭档(`builtin:general`) | 创建会话时继承当前配置,处理日常代码、文件与资料任务;保留原稳定标识以兼容默认选择。 | -| 轻装搭档(`builtin:minimal`) | 使用简短人设、终端(`bash`)和图片读取(`view_image`),保留原生上下文压缩,需要支持读图的模型。 | +| 轻装搭档(`builtin:minimal`) | 使用简短人设和终端(`bash`),保留原生上下文压缩。所选模型支持读图且工具已启用时,额外提供图片读取(`view_image`)。 | -「日常搭档」「轻装搭档」分别替代原显示名称「标准模式」「精简模式」,ID 和能力 -保持不变。已有会话继续保留保存时的名称和版本。 +内置项的稳定 ID 继续兼容已有默认选择。已有会话保留保存时的名称、版本和模块要求。 人设提示参考 DeepSeek Harness 的提交 `687f282e8625c6b8d11ff58211a90524be2e4317`: [standard][harness-standard] 使用简短的编程 Agent 身份提示; @@ -59,7 +58,8 @@ Reasonix 已使用相同的 minimal 开场提示。 轻装搭档不装载自定义技能、命令、MCP、扩展、Hook 或子 Agent,保留回答和提问 所需的宿主控制工具。终端仍能修改文件和访问网络,继续遵守已有权限与沙箱; -轻装搭档不是只读模式。图片能力不可用或所选工具被禁用时会明确报告依赖错误。 +轻装搭档不是只读模式。`bash` 是必需工具,缺失时会报告依赖错误;`view_image` +是可选工具,模型未启用读图能力或配置禁用该工具时,仍可使用终端。 代码审查、资料研究不再作为内置目录项提供;已有会话继续保留原绑定版本。 如果旧默认指向已移除的内置项,目录会提示用户明确选择新的默认项,不自动替换。 @@ -68,6 +68,11 @@ Reasonix 已使用相同的 minimal 开场提示。 「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览 缺失依赖,不会安装依赖或启动程序。 +所选工具成员可声明 `"optional": true`,例如 +`{"id":"view_image","optional":true}`。只有当前已可用的工具才会加入,不会因此 +启用已禁用的工具或授予模型读图能力。不填写 `optional` 时仍为必需工具; +其他模块类型不接受可选成员。 + 设置导航显示为「人设」。编辑页直接展示名称、用途、两段人设文本和可选运行时 上下文开关;工具、技能与扩展按需展开。存放范围与自动生成的 ID 也直接展示, 保存后 ID 与范围不可修改。 diff --git a/internal/assembly/boot/composition.go b/internal/assembly/boot/composition.go index 14ff6f87b6..0ffd4f6480 100644 --- a/internal/assembly/boot/composition.go +++ b/internal/assembly/boot/composition.go @@ -184,6 +184,9 @@ func verifySelected[T any](kind string, s agentcomposition.Selection, items []T, return nil } for _, m := range s.Members { + if kind == "tools" && m.Optional { + continue + } if !slices.ContainsFunc(items, func(v T) bool { return id(v) == m.ID }) { return agentcomposition.Unavailable(kind+":"+m.ID, "Required module is unavailable or was revoked.") } diff --git a/internal/assembly/boot/effect_view_image_test.go b/internal/assembly/boot/effect_view_image_test.go index 689baf4c69..f0f31daab1 100644 --- a/internal/assembly/boot/effect_view_image_test.go +++ b/internal/assembly/boot/effect_view_image_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -24,12 +25,16 @@ func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) { for _, tc := range []struct { vision bool composition string + enabled string + missing string }{ - {true, ""}, {false, ""}, - {true, "builtin:general"}, {false, "builtin:general"}, - {true, "builtin:minimal"}, {false, "builtin:minimal"}, + {true, "", "", ""}, {false, "", "", ""}, + {true, "builtin:general", "", ""}, {false, "builtin:general", "", ""}, + {true, "builtin:minimal", "", ""}, {false, "builtin:minimal", "", ""}, + {true, "builtin:minimal", `["bash"]`, ""}, {false, "builtin:minimal", `["bash"]`, ""}, + {true, "builtin:minimal", `["view_image"]`, "tools:bash"}, } { - t.Run(fmt.Sprintf("vision=%v/composition=%s", tc.vision, tc.composition), func(t *testing.T) { + t.Run(fmt.Sprintf("vision=%v/composition=%s/enabled=%s", tc.vision, tc.composition, tc.enabled), func(t *testing.T) { t.Parallel() home := statedBootHome(t) search := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -37,6 +42,9 @@ func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) { })) t.Cleanup(search.Close) writeFile(t, home, "config.toml", fmt.Sprintf("[tools]\nweb_search_backends = [\"searxng\"]\nsearxng_url = %q\n", search.URL)) + if tc.enabled != "" { + writeFile(t, home, "config.toml", fmt.Sprintf("[tools]\nenabled = %s\nweb_search_backends = [\"searxng\"]\nsearxng_url = %q\n", tc.enabled, search.URL)) + } dir := testenv.TempDir(t) rec := &peerToolProvider{} kind := fmt.Sprintf("boot-view-image-%s", strings.ReplaceAll(t.Name(), "/", "-")) @@ -55,9 +63,10 @@ vision = %v `, kind, tc.vision)) approveWorkspaceAt(t, home, dir) ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard, AgentComposition: tc.composition}) - if tc.composition == "builtin:minimal" && !tc.vision { - if !errors.Is(err, agentcomposition.ErrUnavailable) { - t.Fatalf("text-only model must report unavailable image capability: %v", err) + if tc.missing != "" { + var diagnostic *agentcomposition.DiagnosticError + if !errors.As(err, &diagnostic) || len(diagnostic.Diagnostics) != 1 || diagnostic.Diagnostics[0].Module != tc.missing { + t.Fatalf("error = %v, want missing required module %s", err, tc.missing) } return } @@ -68,11 +77,14 @@ vision = %v ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() _ = ctrl.Run(ctx, "hi") + if !slices.Contains(rec.toolNames(), "bash") { + t.Fatalf("required bash tool missing from provider request: %v", rec.toolNames()) + } has := false for _, name := range rec.toolNames() { has = has || name == "view_image" } - want := tc.vision + want := tc.vision && tc.enabled == "" if has != want { t.Fatalf("vision=%v composition=%s: tools in the first request = %v", tc.vision, tc.composition, rec.toolNames()) } diff --git a/internal/contract/agentcomposition/builtin-revisions.json b/internal/contract/agentcomposition/builtin-revisions.json index 552fccd743..b28a974953 100644 --- a/internal/contract/agentcomposition/builtin-revisions.json +++ b/internal/contract/agentcomposition/builtin-revisions.json @@ -1,4 +1,4 @@ { "builtin:general": "90d6a645648f1767371fedc2c01a487428dc847417c77bddd82a365520a65dfb", - "builtin:minimal": "b8893eec828a9596efe7d1d12264d3124f0041f77d5d29520eecacd1d6d06cdf" + "builtin:minimal": "b25962a98543abdb021332dcb2216d66a59ad90a3395c3d2f7d9cc30186a7a5b" } diff --git a/internal/contract/agentcomposition/builtins.json b/internal/contract/agentcomposition/builtins.json index 555a515c08..02fd19bc71 100644 --- a/internal/contract/agentcomposition/builtins.json +++ b/internal/contract/agentcomposition/builtins.json @@ -9,7 +9,7 @@ { "schemaVersion": 1, "id": "builtin:minimal", "name": "轻装搭档", "description": "带上终端和看图工具,轻装开工。和你一起处理小任务、快速试验,也方便对比模型表现。", "persona": {"prefix": "You are a helpful software engineer assistant.", "suffix": "", "include_runtime_context": false}, - "tools": {"mode":"select","members":[{"id":"bash"},{"id":"view_image"}]}, + "tools": {"mode":"select","members":[{"id":"bash"},{"id":"view_image","optional":true}]}, "skills": {"mode":"select","members":[]}, "mcp": {"mode":"select","members":[]}, "extensions": {"mode":"select","members":[]}, "hooks": {"mode":"select","members":[]}, "commands": {"mode":"select","members":[]} } diff --git a/internal/contract/agentcomposition/composition.go b/internal/contract/agentcomposition/composition.go index 14981c9c9f..cb8fc341ea 100644 --- a/internal/contract/agentcomposition/composition.go +++ b/internal/contract/agentcomposition/composition.go @@ -32,6 +32,7 @@ type Persona struct { type Module struct { ID string `json:"id"` Parameters json.RawMessage `json:"parameters,omitempty"` + Optional bool `json:"optional,omitempty"` } type Selection struct { @@ -140,7 +141,7 @@ func (d Definition) Validate() error { if len(d.Persona.Prefix) > 32768 || len(d.Persona.Suffix) > 32768 { return fmt.Errorf("%w: persona", ErrInvalid) } - for _, s := range []Selection{d.Tools, d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} { + for group, s := range []Selection{d.Tools, d.Skills, d.MCP, d.Extensions, d.Hooks, d.Commands} { if len(s.Members) > 10000 { return ErrInvalid } @@ -149,6 +150,9 @@ func (d Definition) Validate() error { } seen := map[string]bool{} for _, m := range s.Members { + if m.Optional && group != 0 { + return fmt.Errorf("%w: optional is only supported for tools", ErrInvalid) + } if m.ID == "" || len(m.ID) > 1024 || seen[m.ID] { return fmt.Errorf("%w: duplicate or empty module %q", ErrInvalid, m.ID) } diff --git a/internal/contract/agentcomposition/composition_test.go b/internal/contract/agentcomposition/composition_test.go index ec755ecf85..663677bf79 100644 --- a/internal/contract/agentcomposition/composition_test.go +++ b/internal/contract/agentcomposition/composition_test.go @@ -1,6 +1,7 @@ package agentcomposition import ( + "encoding/json" "errors" "testing" ) @@ -46,8 +47,116 @@ func TestMinimalTemplateHasNoAutomationOrDelegation(t *testing.T) { t.Fatalf("%s inherits modules", d.ID) } } - if d.Tools.Mode != Select || len(d.Tools.Members) != 2 || d.Tools.Members[0].ID != "bash" || d.Tools.Members[1].ID != "view_image" { + if d.Tools.Mode != Select || len(d.Tools.Members) != 2 || d.Tools.Members[0].ID != "bash" || d.Tools.Members[0].Optional || d.Tools.Members[1].ID != "view_image" || !d.Tools.Members[1].Optional { t.Fatalf("minimal tools = %+v", d.Tools) } } } + +func TestOptionalModulesDecodeAndValidate(t *testing.T) { + for _, group := range []string{"tools", "skills", "mcp", "extensions", "hooks", "commands"} { + t.Run(group, func(t *testing.T) { + d := Builtins()[0] + data, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var document map[string]json.RawMessage + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + document[group] = json.RawMessage(`{"mode":"select","members":[{"id":"example","optional":true}]}`) + data, err = json.Marshal(document) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(data) + if group != "tools" { + if !errors.Is(err, ErrInvalid) { + t.Fatalf("optional %s decode error = %v", group, err) + } + if !errors.Is(decoded.Validate(), ErrInvalid) { + t.Fatalf("optional %s passed structural validation", group) + } + return + } + if err != nil { + t.Fatal(err) + } + if !decoded.Tools.Members[0].Optional { + t.Fatal("optional tool lost during decode") + } + }) + } +} + +func TestOptionalToolsBindingRoundTrip(t *testing.T) { + d := Builtins()[1] + binding := Binding{ + Resolved: d, + Version: Version{Definition: d, Revision: Revision(d)}, + } + binding.Seal() + restored := Clone(binding) + if err := restored.Validate(); err != nil { + t.Fatal(err) + } + for _, definition := range []Definition{restored.Resolved, restored.Version.Definition} { + if definition.Tools.Members[0].Optional || !definition.Tools.Members[1].Optional { + t.Fatalf("tool requirements changed during binding roundtrip: %+v", definition.Tools) + } + } + restored.Resolved.Tools.Members[1].Optional = false + if !errors.Is(restored.Validate(), ErrInvalid) { + t.Fatal("changing optional requirement did not invalidate binding digest") + } +} + +func TestAbsentOptionalPreservesRevisionAndBindingDigest(t *testing.T) { + // A legacy declaration has no optional field; false must keep that encoding. + d := Builtins()[0] + d.Tools = Selection{Mode: Select, Members: []Module{{ID: "bash"}}} + data, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var legacy map[string]json.RawMessage + if err := json.Unmarshal(data, &legacy); err != nil { + t.Fatal(err) + } + legacy["tools"] = json.RawMessage(`{"mode":"select","members":[{"id":"bash"}]}`) + if Revision(d) != Digest(legacy) { + t.Fatal("absent optional changed legacy revision") + } + decoded, err := Decode(data) + if err != nil { + t.Fatal(err) + } + if decoded.Tools.Members[0].Optional { + t.Fatal("explicit tool became optional by default") + } + binding := Binding{Resolved: decoded, Version: Version{Definition: decoded, Revision: Revision(decoded)}} + binding.Seal() + var legacyBinding map[string]json.RawMessage + data, err = json.Marshal(binding) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &legacyBinding); err != nil { + t.Fatal(err) + } + legacyData, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + legacyBinding["resolved"] = legacyData + versionData, err := json.Marshal(map[string]any{"definition": legacy, "revision": Revision(decoded)}) + if err != nil { + t.Fatal(err) + } + legacyBinding["version"] = versionData + legacyBinding["digest"] = json.RawMessage(`""`) + if binding.Digest != Digest(legacyBinding) { + t.Fatal("absent optional changed legacy binding digest") + } +} diff --git a/internal/frontend/serve/agent_presets.go b/internal/frontend/serve/agent_presets.go index 8d68079ac6..687a71e36b 100644 --- a/internal/frontend/serve/agent_presets.go +++ b/internal/frontend/serve/agent_presets.go @@ -204,6 +204,9 @@ func (s *Server) importAgentPreset(w http.ResponseWriter, r *http.Request) { selections := map[string]agentcomposition.Selection{"tools": d.Tools, "skills": d.Skills, "mcp": d.MCP, "extensions": d.Extensions, "hooks": d.Hooks, "commands": d.Commands} for kind, selection := range selections { for _, m := range selection.Members { + if m.Optional { + continue + } if !slices.ContainsFunc(catalog.Modules[kind], func(available agentpreset.Choice) bool { return available.ID == m.ID }) { diagnostics = append(diagnostics, agentcomposition.Diagnostic{Code: "agent_composition.unavailable", Module: kind + ":" + m.ID, Detail: "Dependency is not available on this host."}) } diff --git a/internal/frontend/serve/agent_presets_test.go b/internal/frontend/serve/agent_presets_test.go index 67e9ae951f..c3d1ff1222 100644 --- a/internal/frontend/serve/agent_presets_test.go +++ b/internal/frontend/serve/agent_presets_test.go @@ -40,3 +40,38 @@ func TestAgentPresetRoutesKeepRevisionsAndRejectStaleWrites(t *testing.T) { t.Fatal("version missing") } } + +func TestAgentPresetImportOnlyReportsMissingRequiredTools(t *testing.T) { + s := newProviderEditServer(t) + s.AllowProviderEdit() + srv := httptest.NewServer(operatorHandler(s)) + defer srv.Close() + for _, optional := range []bool{false, true} { + d := agentcomposition.Builtins()[0] + d.ID = "user:imported" + d.Tools = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{{ID: "unavailable-image-reader", Optional: optional}}} + body, err := json.Marshal(map[string]any{"content": d}) + if err != nil { + t.Fatal(err) + } + resp := postProvider(t, srv.URL, "/agent-presets/import", string(body)) + var preview struct { + Diagnostics []agentcomposition.Diagnostic `json:"diagnostics"` + } + err = json.NewDecoder(resp.Body).Decode(&preview) + resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("optional=%v: import status %d", optional, resp.StatusCode) + } + if optional { + if len(preview.Diagnostics) != 0 { + t.Fatalf("optional tool refused: %+v", preview.Diagnostics) + } + } else if len(preview.Diagnostics) != 1 || preview.Diagnostics[0].Code != "agent_composition.unavailable" || preview.Diagnostics[0].Module != "tools:unavailable-image-reader" { + t.Fatalf("required tool diagnostic = %+v", preview.Diagnostics) + } + } +}