Merge pull request #14152 from lancy69/fix/14126-minimal-image-capability

fix(composition): allow lightweight mode without image capability
This commit is contained in:
YHH authored and GitHub committed 2026-10-23 05:39:10 -07:00
commit 493736c685
13 files changed
+238 -27

No files matched your search

@@ -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<string, unknown> }
export interface CompositionModule { id: string; parameters?: Record<string, unknown>; 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; source?: "builtin" | "custom" }
@@ -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"}});
});
});
@@ -38,12 +38,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");
@@ -55,7 +58,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];
@@ -81,10 +84,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};
}
+13 -5
View File
@@ -74,10 +74,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`:
@@ -99,8 +99,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
@@ -110,6 +111,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.
+9 -4
View File
@@ -51,10 +51,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 身份提示;
@@ -70,7 +69,8 @@ Reasonix 已使用相同的 minimal 开场提示。
轻装搭档不装载自定义技能、命令、MCP、扩展、Hook 或子 Agent,保留回答和提问
所需的宿主控制工具。终端仍能修改文件和访问网络,继续遵守已有权限与沙箱;
轻装搭档不是只读模式。图片能力不可用或所选工具被禁用时会明确报告依赖错误。
轻装搭档不是只读模式。`bash` 是必需工具,缺失时会报告依赖错误;`view_image`
是可选工具,模型未启用读图能力或配置禁用该工具时,仍可使用终端。
代码审查、资料研究不再作为内置目录项提供;已有会话继续保留原绑定版本。
如果旧默认指向已移除的内置项,目录会提示用户明确选择新的默认项,不自动替换。
@@ -79,6 +79,11 @@ Reasonix 已使用相同的 minimal 开场提示。
「仅使用所选项」;显式空列表表示禁用。编辑器提供本机模块目录,导入先预览
缺失依赖,不会安装依赖或启动程序。
所选工具成员可声明 `"optional": true`,例如
`{"id":"view_image","optional":true}`。只有当前已可用的工具才会加入,不会因此
启用已禁用的工具或授予模型读图能力。不填写 `optional` 时仍为必需工具;
其他模块类型不接受可选成员。
设置导航显示为「人设」。编辑页直接展示名称、用途、两段人设文本和可选运行时
上下文开关;工具、技能与扩展按需展开。存放范围与自动生成的 ID 也直接展示,
保存后 ID 与范围不可修改。
+3
View File
@@ -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.")
}
@@ -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())
}
@@ -1,4 +1,4 @@
{
"builtin:general": "90d6a645648f1767371fedc2c01a487428dc847417c77bddd82a365520a65dfb",
"builtin:minimal": "b8893eec828a9596efe7d1d12264d3124f0041f77d5d29520eecacd1d6d06cdf"
"builtin:minimal": "b25962a98543abdb021332dcb2216d66a59ad90a3395c3d2f7d9cc30186a7a5b"
}
@@ -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":[]}
}
@@ -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)
}
@@ -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")
}
}
+3
View File
@@ -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."})
}
@@ -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)
}
}
}