From 747963fd1dbf466405c9249c07264ad5ba70172b Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 15:36:55 +0800 Subject: [PATCH 1/6] feat(compose): add agent input and output schemas --- cmd/agent-compose/cli_compose_config.go | 1 + docs/pages/agent-compose-yaml-manual.md | 24 +++ docs/pages/zh-CN/agent-compose-yaml-manual.md | 24 +++ pkg/agentcompose/api/project_spec_mapper.go | 47 +++-- .../api/project_spec_mapper_schema_test.go | 82 +++++++++ pkg/agentcompose/api/project_yaml_shape.go | 27 +++ pkg/compose/canonical_json.go | 2 + pkg/compose/json_schema_source.go | 164 ++++++++++++++++++ pkg/compose/json_schema_source_test.go | 94 ++++++++++ pkg/compose/normalize.go | 93 ++++++---- pkg/compose/output.go | 40 ++++- pkg/compose/spec.go | 4 + .../testdata/compat/contract-v2608.1.0.json | 10 ++ pkg/projects/records.go | 6 +- pkg/projects/records_test.go | 14 ++ proto/agentcompose/v2/agentcompose.proto | 4 + 16 files changed, 576 insertions(+), 60 deletions(-) create mode 100644 pkg/agentcompose/api/project_spec_mapper_schema_test.go create mode 100644 pkg/compose/json_schema_source.go create mode 100644 pkg/compose/json_schema_source_test.go diff --git a/cmd/agent-compose/cli_compose_config.go b/cmd/agent-compose/cli_compose_config.go index 2c4cbca47..0d73f08c8 100644 --- a/cmd/agent-compose/cli_compose_config.go +++ b/cmd/agent-compose/cli_compose_config.go @@ -35,6 +35,7 @@ func loadNormalizedComposeWithOptions(ctx context.Context, cli cliOptions, resol ComposePath: composePath, Env: projectEnv, ResolveScriptURLs: resolveScriptURLs, + ResolveSchemaURLs: resolveScriptURLs, Context: ctx, }) if err != nil { diff --git a/docs/pages/agent-compose-yaml-manual.md b/docs/pages/agent-compose-yaml-manual.md index 0396fcbfa..10de45eae 100644 --- a/docs/pages/agent-compose-yaml-manual.md +++ b/docs/pages/agent-compose-yaml-manual.md @@ -426,6 +426,8 @@ agents: | `enabled` | bool | `true` | Whether the Agent is enabled. A disabled definition remains stored but cannot run normally, and its scheduler is not enabled. | | `display_name` | string | Empty | Human-readable agent label. | | `description` | string | Empty | Human-readable explanation of the agent's role. | +| `input_schema` | JSON Schema/source | None | Optional JSON Schema describing input accepted by the agent. May be inline or loaded from a source descriptor. | +| `output_schema` | JSON Schema/source | None | Optional JSON Schema describing output produced by the agent. May be inline or loaded from a source descriptor. | | `provider` | string | `codex` | Agent provider: `codex`, `claude`, `gemini`, `opencode`, `pi`, or `dsh`. Compatibility aliases are normalized at persistence boundaries. | | `model` | string | Provider/daemon default | Model name. Pi and dsh require `/`. Supports `${NAME}` interpolation. | | `system_prompt` | string | Empty | Additional system instructions; YAML block scalars are recommended for multiline text. | @@ -442,6 +444,28 @@ agents: | `scheduler` | object | None | Automatic trigger configuration. | | `jupyter` | object | Disabled | Default Jupyter behavior for agent runs. | +### `input_schema` and `output_schema` + +Each schema is optional and independent. An agent may declare either one, both, or neither. Inline schemas use ordinary JSON Schema expressed as YAML; property-level `description` values are recommended so external platforms can present useful input and output documentation. + +```yaml +agents: + researcher: + description: Researches a topic and returns cited findings. + input_schema: + type: object + required: [query] + properties: + query: + type: string + description: Topic or question to research. + output_schema: + provider: file + path: ./schemas/research-result.schema.json +``` + +The source form is the same flat descriptor accepted by `scheduler.script` (`file`, `http`, or `git`). Relative file paths resolve from the compose file directory. Source content is resolved and stored as a snapshot when the project is applied; it must contain a JSON object or boolean schema. A mapping containing `provider` is interpreted as a source descriptor, so an inline schema that needs a custom top-level `provider` keyword should place that schema in a referenced file. + ### `enabled`, `provider`, `model`, and `system_prompt` ```yaml diff --git a/docs/pages/zh-CN/agent-compose-yaml-manual.md b/docs/pages/zh-CN/agent-compose-yaml-manual.md index ce812583d..1ec710da1 100644 --- a/docs/pages/zh-CN/agent-compose-yaml-manual.md +++ b/docs/pages/zh-CN/agent-compose-yaml-manual.md @@ -427,6 +427,8 @@ agents: | `enabled` | bool | `true` | 是否启用 Agent。禁用后定义保留但不可按正常流程运行,Scheduler 也不会启用。 | | `display_name` | string | 空 | Agent 的可读显示名称。 | | `description` | string | 空 | Agent 职责的可读说明。 | +| `input_schema` | JSON Schema/source | 无 | 可选,描述 Agent 可接收输入的 JSON Schema;可内联或通过 source descriptor 加载。 | +| `output_schema` | JSON Schema/source | 无 | 可选,描述 Agent 输出的 JSON Schema;可内联或通过 source descriptor 加载。 | | `provider` | string | `codex` | Agent CLI/provider:`codex`、`claude`、`gemini`、`opencode`、`pi` 或 `dsh`。兼容别名会在持久化边界归一化。 | | `model` | string | provider/daemon 默认 | 模型名;Pi 和 dsh 要求使用 `/`;支持 `${NAME}` 插值。 | | `system_prompt` | string | 空 | 附加的系统提示,适合使用 YAML `|` 多行标量。 | @@ -443,6 +445,28 @@ agents: | `scheduler` | object | 无 | 自动触发 Agent 的 Scheduler。 | | `jupyter` | object | disabled | Agent run 的 Jupyter 默认配置。 | +### `input_schema` 与 `output_schema` + +两个 schema 都是独立可选项,Agent 可以只声明其中一个、同时声明两个,或都不声明。内联形式直接使用 YAML 表达 JSON Schema;建议为属性填写 `description`,便于外部平台展示输入输出说明。 + +```yaml +agents: + researcher: + description: 调研指定主题并返回带引用的结论。 + input_schema: + type: object + required: [query] + properties: + query: + type: string + description: 需要调研的主题或问题。 + output_schema: + provider: file + path: ./schemas/research-result.schema.json +``` + +source 形式与 `scheduler.script` 接受的扁平 descriptor 一致(`file`、`http` 或 `git`)。相对文件路径以 compose 文件所在目录为基准。应用项目时会解析内容并保存快照,内容必须是 JSON object 或 boolean schema。包含 `provider` 的 mapping 会被解释为 source descriptor;如果内联 schema 需要自定义的顶层 `provider` 关键字,请改用文件引用。 + ### `enabled`、`provider`、`model` 和 `system_prompt` ```yaml diff --git a/pkg/agentcompose/api/project_spec_mapper.go b/pkg/agentcompose/api/project_spec_mapper.go index 40ccb98fe..422fb3039 100644 --- a/pkg/agentcompose/api/project_spec_mapper.go +++ b/pkg/agentcompose/api/project_spec_mapper.go @@ -57,30 +57,39 @@ func AgentSpecsToProto(agents []compose.NormalizedAgentSpec) []*agentcomposev2.A items := make([]*agentcomposev2.AgentSpec, 0, len(agents)) for _, agent := range agents { items = append(items, &agentcomposev2.AgentSpec{ - Name: agent.Name, - DisplayName: agent.DisplayName, - Description: agent.Description, - Provider: agent.Provider, - Model: agent.Model, - SystemPrompt: agent.SystemPrompt, - Image: agent.Image, - Build: BuildSpecToProto(agent.Build), - Driver: DriverSpecToProto(agent.Driver), - Env: EnvVarSpecsToProto(agent.Env), - CapsetIds: capabilities.NormalizeCapsetIDs(agent.CapsetIDs), - Skills: SkillSpecsToProto(agent.Skills), - Workspace: WorkspaceSpecToProto(agent.Workspace), - Sandbox: SandboxSpecToProto(agent.Sandbox), - Scheduler: SchedulerSpecToProto(agent.Scheduler), - Jupyter: JupyterSpecToProto(agent.Jupyter), - Volumes: VolumeMountSpecsToProto(agent.Volumes), - McpServers: MCPServerSpecsToProto(agent.MCPServers), - Enabled: &agent.Enabled, + Name: agent.Name, + DisplayName: agent.DisplayName, + Description: agent.Description, + InputSchemaJson: jsonSchemaString(agent.InputSchema), + OutputSchemaJson: jsonSchemaString(agent.OutputSchema), + Provider: agent.Provider, + Model: agent.Model, + SystemPrompt: agent.SystemPrompt, + Image: agent.Image, + Build: BuildSpecToProto(agent.Build), + Driver: DriverSpecToProto(agent.Driver), + Env: EnvVarSpecsToProto(agent.Env), + CapsetIds: capabilities.NormalizeCapsetIDs(agent.CapsetIDs), + Skills: SkillSpecsToProto(agent.Skills), + Workspace: WorkspaceSpecToProto(agent.Workspace), + Sandbox: SandboxSpecToProto(agent.Sandbox), + Scheduler: SchedulerSpecToProto(agent.Scheduler), + Jupyter: JupyterSpecToProto(agent.Jupyter), + Volumes: VolumeMountSpecsToProto(agent.Volumes), + McpServers: MCPServerSpecsToProto(agent.MCPServers), + Enabled: &agent.Enabled, }) } return items } +func jsonSchemaString(schema *compose.JSONSchema) string { + if schema == nil { + return "" + } + return string(*schema) +} + func SandboxSpecToProto(sandbox *compose.NormalizedSandboxSpec) *agentcomposev2.SandboxSpec { if sandbox == nil { return nil diff --git a/pkg/agentcompose/api/project_spec_mapper_schema_test.go b/pkg/agentcompose/api/project_spec_mapper_schema_test.go new file mode 100644 index 000000000..6c557f7c4 --- /dev/null +++ b/pkg/agentcompose/api/project_spec_mapper_schema_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "testing" + + "agent-compose/pkg/compose" + agentcomposev2 "agent-compose/proto/agentcompose/v2" + + "gopkg.in/yaml.v3" +) + +func TestAgentSpecsToProtoIncludesJSONSchemas(t *testing.T) { + input := compose.JSONSchema(`{"type":"object"}`) + output := compose.JSONSchema(`false`) + items := AgentSpecsToProto([]compose.NormalizedAgentSpec{{ + Name: "worker", + InputSchema: &input, + OutputSchema: &output, + }}) + if len(items) != 1 || items[0].InputSchemaJson != `{"type":"object"}` || items[0].OutputSchemaJson != "false" { + t.Fatalf("mapped agent = %#v", items) + } +} + +func TestProjectSpecSchemaProtoRoundTripPreservesHash(t *testing.T) { + parsedOriginal, err := compose.Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: object\n properties:\n count:\n type: integer\n default: 3\n output_schema:\n type: string\n")) + if err != nil { + t.Fatal(err) + } + original, err := compose.Normalize(parsedOriginal, compose.NormalizeOptions{}) + if err != nil { + t.Fatal(err) + } + wantHash, err := original.Hash() + if err != nil { + t.Fatal(err) + } + shape, issues := ProjectSpecYAMLShape(ProjectSpecToProto(original)) + if len(issues) != 0 { + t.Fatalf("ProjectSpecYAMLShape issues = %#v", issues) + } + data, err := yaml.Marshal(shape) + if err != nil { + t.Fatal(err) + } + parsed, err := compose.Parse(data) + if err != nil { + t.Fatal(err) + } + roundTrip, err := compose.Normalize(parsed, compose.NormalizeOptions{}) + if err != nil { + t.Fatal(err) + } + gotHash, err := roundTrip.Hash() + if err != nil { + t.Fatal(err) + } + if gotHash != wantHash { + t.Fatalf("round-trip hash = %s, want %s\nshape:\n%s", gotHash, wantHash, data) + } +} + +func TestAgentYAMLMapRestoresJSONSchemas(t *testing.T) { + input := compose.JSONSchema(`{"type":"object"}`) + output := compose.JSONSchema(`false`) + protoAgents := AgentSpecsToProto([]compose.NormalizedAgentSpec{{Name: "worker", InputSchema: &input, OutputSchema: &output}}) + agents, issues := AgentYAMLMap(protoAgents) + if len(issues) != 0 { + t.Fatalf("AgentYAMLMap issues = %#v", issues) + } + worker, ok := agents["worker"].(map[string]any) + if !ok || worker["input_schema"] == nil || worker["output_schema"] != false { + t.Fatalf("restored agent = %#v", agents["worker"]) + } +} + +func TestAgentYAMLMapRejectsInvalidJSONSchema(t *testing.T) { + _, issues := AgentYAMLMap([]*agentcomposev2.AgentSpec{{Name: "worker", InputSchemaJson: "[]"}}) + if len(issues) != 1 || issues[0].GetPath() != "agents[0].input_schema_json" { + t.Fatalf("issues = %#v", issues) + } +} diff --git a/pkg/agentcompose/api/project_yaml_shape.go b/pkg/agentcompose/api/project_yaml_shape.go index 36e6750c3..6ae048358 100644 --- a/pkg/agentcompose/api/project_yaml_shape.go +++ b/pkg/agentcompose/api/project_yaml_shape.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "fmt" "strings" @@ -111,6 +112,16 @@ func AgentYAMLMap(agents []*agentcomposev2.AgentSpec) (map[string]any, []*agentc if strings.TrimSpace(agent.GetDescription()) != "" { raw["description"] = agent.GetDescription() } + if schema, issue := agentJSONSchemaYAMLValue(fmt.Sprintf("agents[%d].input_schema_json", i), agent.GetInputSchemaJson()); issue != nil { + return nil, []*agentcomposev2.ProjectValidationIssue{issue} + } else if schema != nil { + raw["input_schema"] = schema + } + if schema, issue := agentJSONSchemaYAMLValue(fmt.Sprintf("agents[%d].output_schema_json", i), agent.GetOutputSchemaJson()); issue != nil { + return nil, []*agentcomposev2.ProjectValidationIssue{issue} + } else if schema != nil { + raw["output_schema"] = schema + } if agent.Enabled != nil { raw["enabled"] = agent.GetEnabled() } @@ -172,6 +183,22 @@ func AgentYAMLMap(agents []*agentcomposev2.AgentSpec) (map[string]any, []*agentc return values, nil } +func agentJSONSchemaYAMLValue(path, raw string) (any, *agentcomposev2.ProjectValidationIssue) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, ProjectValidationIssue(path, "must contain valid JSON") + } + switch value.(type) { + case map[string]any, bool: + return value, nil + default: + return nil, ProjectValidationIssue(path, "must contain a JSON Schema object or boolean") + } +} + func MCPServerYAMLMap(path string, mcps []*agentcomposev2.MCPServerSpec) (map[string]any, []*agentcomposev2.ProjectValidationIssue) { values := make(map[string]any, len(mcps)) for i, mcp := range mcps { diff --git a/pkg/compose/canonical_json.go b/pkg/compose/canonical_json.go index 0c18a5359..6869e3ac8 100644 --- a/pkg/compose/canonical_json.go +++ b/pkg/compose/canonical_json.go @@ -36,6 +36,8 @@ func normalizedProjectSpecFromOrdered(ordered orderedProjectSpec) *NormalizedPro Enabled: agent.Enabled, DisplayName: agent.DisplayName, Description: agent.Description, + InputSchema: cloneJSONSchema(agent.InputSchema), + OutputSchema: cloneJSONSchema(agent.OutputSchema), Provider: agent.Provider, Model: agent.Model, SystemPrompt: agent.SystemPrompt, diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go new file mode 100644 index 000000000..fe14ac1dd --- /dev/null +++ b/pkg/compose/json_schema_source.go @@ -0,0 +1,164 @@ +package compose + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "agent-compose/pkg/sources" + + "gopkg.in/yaml.v3" +) + +// JSONSchemaSource accepts either an inline JSON Schema or the same source +// descriptor shape used by scheduler.script. A mapping containing provider is +// treated as a source descriptor; every other mapping and boolean is inline. +type JSONSchemaSource struct { + Inline *JSONSchema + Source sources.Source +} + +func (s JSONSchemaSource) IsZero() bool { return s.Inline == nil && !s.Source.HasContent() } + +func (s *JSONSchemaSource) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.MappingNode && mappingHasKey(value, "provider") { + var source sources.Source + if err := value.Decode(&source); err != nil { + return err + } + *s = JSONSchemaSource{Source: source} + return nil + } + if value.Kind != yaml.MappingNode && (value.Kind != yaml.ScalarNode || (value.Tag != "!!bool" && value.Tag != "!!null")) { + return fmt.Errorf("JSON Schema must be an object or boolean") + } + if value.Tag == "!!null" { + return fmt.Errorf("JSON Schema must not be null") + } + var decoded any + if err := value.Decode(&decoded); err != nil { + return err + } + data, err := json.Marshal(decoded) + if err != nil { + return fmt.Errorf("encode JSON Schema: %w", err) + } + schema := JSONSchema(data) + *s = JSONSchemaSource{Inline: &schema} + return nil +} + +func (s JSONSchemaSource) MarshalYAML() (any, error) { + if s.Source.HasContent() { + return s.Source, nil + } + if s.Inline == nil { + return nil, nil + } + return s.Inline.yamlValue() +} + +// JSONSchema stores a normalized JSON representation while preserving boolean +// schemas and arbitrary extension keywords. +type JSONSchema json.RawMessage + +func (s JSONSchema) MarshalJSON() ([]byte, error) { return bytes.Clone(s), nil } + +func (s *JSONSchema) UnmarshalJSON(data []byte) error { + canonical, err := canonicalJSONSchemaDocument(data) + if err != nil { + return err + } + *s = canonical + return nil +} + +func (s JSONSchema) MarshalYAML() (any, error) { return s.yamlValue() } + +func (s JSONSchema) yamlValue() (any, error) { + var value any + if err := json.Unmarshal(s, &value); err != nil { + return nil, err + } + return value, nil +} + +func validateJSONSchemaDocument(data []byte) error { + _, err := canonicalJSONSchemaDocument(data) + return err +} + +func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, fmt.Errorf("invalid JSON Schema: %w", err) + } + switch value.(type) { + case map[string]any, bool: + canonical, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode JSON Schema: %w", err) + } + return canonical, nil + default: + return nil, fmt.Errorf("JSON Schema must be an object or boolean") + } +} + +func mappingHasKey(node *yaml.Node, key string) bool { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return true + } + } + return false +} + +func validateJSONSchemaSource(node *yaml.Node, path string) error { + if node.Kind == yaml.MappingNode && mappingHasKey(node, "provider") { + return validateMapping(node, path, sourceFieldValidators(nil)) + } + if node.Kind == yaml.MappingNode || (node.Kind == yaml.ScalarNode && node.Tag == "!!bool") { + return nil + } + return newParseError(node, path, "expected a JSON Schema object or boolean, or a source mapping") +} + +func normalizeJSONSchemaSource(path string, source JSONSchemaSource, options NormalizeOptions) (*JSONSchema, *sources.Source, error) { + if source.IsZero() { + return nil, nil, nil + } + if source.Inline != nil { + if err := validateJSONSchemaDocument(*source.Inline); err != nil { + return nil, nil, &ValidationError{Path: path, Message: err.Error()} + } + cloned := JSONSchema(bytes.Clone(*source.Inline)) + return &cloned, nil, nil + } + normalizedSource, err := normalizeSchedulerScriptSource(path, source.Source, options) + if err != nil { + return nil, nil, err + } + if !options.ResolveSchemaURLs { + return nil, &normalizedSource, nil + } + resolver := options.ScriptSourceResolver + if resolver == nil { + resolver = NewDefaultScriptSourceResolver(options.Env) + } + ctx := options.Context + if ctx == nil { + ctx = context.Background() + } + content, err := resolver.Resolve(ctx, normalizedSource) + if err != nil { + return nil, nil, &ValidationError{Path: path, Message: err.Error()} + } + canonical, err := canonicalJSONSchemaDocument(content) + if err != nil { + return nil, nil, &ValidationError{Path: path, Message: err.Error()} + } + schema := JSONSchema(canonical) + return &schema, nil, nil +} diff --git a/pkg/compose/json_schema_source_test.go b/pkg/compose/json_schema_source_test.go new file mode 100644 index 000000000..7720a6995 --- /dev/null +++ b/pkg/compose/json_schema_source_test.go @@ -0,0 +1,94 @@ +package compose + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentJSONSchemasAreOptionalAndIndependent(t *testing.T) { + spec, err := Parse([]byte(` +name: schemas +agents: + input-only: + input_schema: + type: object + description: Request accepted by the agent + properties: + query: + type: string + description: Search query + output-only: + output_schema: false +`)) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + normalized, err := Normalize(spec, NormalizeOptions{}) + if err != nil { + t.Fatalf("Normalize returned error: %v", err) + } + if normalized.Agents[0].InputSchema == nil || normalized.Agents[0].OutputSchema != nil { + t.Fatalf("input-only schemas = %#v/%#v", normalized.Agents[0].InputSchema, normalized.Agents[0].OutputSchema) + } + if normalized.Agents[1].InputSchema != nil || normalized.Agents[1].OutputSchema == nil { + t.Fatalf("output-only schemas = %#v/%#v", normalized.Agents[1].InputSchema, normalized.Agents[1].OutputSchema) + } + data, err := normalized.MarshalCanonicalJSON(false) + if err != nil { + t.Fatalf("MarshalCanonicalJSON returned error: %v", err) + } + roundTrip, err := ParseCanonicalJSON(data) + if err != nil { + t.Fatalf("ParseCanonicalJSON returned error: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(*roundTrip.Agents[0].InputSchema, &schema); err != nil { + t.Fatalf("decode input schema: %v", err) + } + if schema["description"] != "Request accepted by the agent" { + t.Fatalf("input schema = %#v", schema) + } +} + +func TestAgentJSONSchemaFileSourceIsSnapshotted(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "request.schema.json") + if err := os.WriteFile(schemaPath, []byte(`{"type":"object","properties":{"count":{"type":"integer"}}}`), 0o600); err != nil { + t.Fatal(err) + } + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n provider: file\n path: request.schema.json\n")) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + normalized, err := Normalize(spec, NormalizeOptions{ComposePath: filepath.Join(dir, "agent-compose.yml"), ResolveSchemaURLs: true}) + if err != nil { + t.Fatalf("Normalize returned error: %v", err) + } + if normalized.Agents[0].InputSchema == nil || !strings.Contains(string(*normalized.Agents[0].InputSchema), `"count"`) { + t.Fatalf("resolved input schema = %v", normalized.Agents[0].InputSchema) + } +} + +func TestUnresolvedAgentJSONSchemaSourceCannotBePersisted(t *testing.T) { + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n provider: file\n path: request.schema.json\n")) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + normalized, err := Normalize(spec, NormalizeOptions{ComposePath: filepath.Join(t.TempDir(), "agent-compose.yml")}) + if err != nil { + t.Fatalf("Normalize returned error: %v", err) + } + if _, err := normalized.Redacted().MarshalCanonicalJSON(false); err == nil || !strings.Contains(err.Error(), "input_schema") { + t.Fatalf("MarshalCanonicalJSON error = %v", err) + } +} + +func TestAgentJSONSchemaRejectsNonSchemaDocument(t *testing.T) { + _, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema: [string]\n")) + if err == nil || !strings.Contains(err.Error(), "JSON Schema") { + t.Fatalf("Parse error = %v", err) + } +} diff --git a/pkg/compose/normalize.go b/pkg/compose/normalize.go index ffb92de32..f5c2b1587 100644 --- a/pkg/compose/normalize.go +++ b/pkg/compose/normalize.go @@ -35,6 +35,7 @@ type NormalizeOptions struct { Env map[string]string SourceCredentials SourceCredentialMode ResolveScriptURLs bool + ResolveSchemaURLs bool ScriptSourceResolver ScriptSourceResolver Context context.Context } @@ -50,25 +51,29 @@ type NormalizedProjectSpec struct { } type NormalizedAgentSpec struct { - Name string `yaml:"name" json:"name"` - Enabled bool `yaml:"enabled" json:"enabled"` - DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` - Model string `yaml:"model,omitempty" json:"model,omitempty"` - SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"` - Image string `yaml:"image,omitempty" json:"image,omitempty"` - Build *NormalizedBuildSpec `yaml:"build,omitempty" json:"build,omitempty"` - Driver *NormalizedDriverSpec `yaml:"driver" json:"driver"` - Env map[string]EnvVarSpec `yaml:"env,omitempty" json:"env,omitempty"` - MCPServers map[string]NormalizedMCPServerSpec `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` - CapsetIDs []string `yaml:"capset_ids,omitempty" json:"capset_ids,omitempty"` - Skills []NormalizedSkillSpec `yaml:"skills,omitempty" json:"skills,omitempty"` - Volumes []NormalizedVolumeMountSpec `yaml:"volumes,omitempty" json:"volumes,omitempty"` - Workspace *WorkspaceSpec `yaml:"workspace,omitempty" json:"workspace,omitempty"` - Sandbox *NormalizedSandboxSpec `yaml:"sandbox,omitempty" json:"sandbox,omitempty"` - Scheduler *NormalizedSchedulerSpec `yaml:"scheduler,omitempty" json:"scheduler,omitempty"` - Jupyter *JupyterSpec `yaml:"jupyter,omitempty" json:"jupyter,omitempty"` + Name string `yaml:"name" json:"name"` + Enabled bool `yaml:"enabled" json:"enabled"` + DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + InputSchema *JSONSchema `yaml:"input_schema,omitempty" json:"input_schema,omitempty"` + OutputSchema *JSONSchema `yaml:"output_schema,omitempty" json:"output_schema,omitempty"` + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + Model string `yaml:"model,omitempty" json:"model,omitempty"` + SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"` + Image string `yaml:"image,omitempty" json:"image,omitempty"` + Build *NormalizedBuildSpec `yaml:"build,omitempty" json:"build,omitempty"` + Driver *NormalizedDriverSpec `yaml:"driver" json:"driver"` + Env map[string]EnvVarSpec `yaml:"env,omitempty" json:"env,omitempty"` + MCPServers map[string]NormalizedMCPServerSpec `yaml:"mcp_servers,omitempty" json:"mcp_servers,omitempty"` + CapsetIDs []string `yaml:"capset_ids,omitempty" json:"capset_ids,omitempty"` + Skills []NormalizedSkillSpec `yaml:"skills,omitempty" json:"skills,omitempty"` + Volumes []NormalizedVolumeMountSpec `yaml:"volumes,omitempty" json:"volumes,omitempty"` + Workspace *WorkspaceSpec `yaml:"workspace,omitempty" json:"workspace,omitempty"` + Sandbox *NormalizedSandboxSpec `yaml:"sandbox,omitempty" json:"sandbox,omitempty"` + Scheduler *NormalizedSchedulerSpec `yaml:"scheduler,omitempty" json:"scheduler,omitempty"` + Jupyter *JupyterSpec `yaml:"jupyter,omitempty" json:"jupyter,omitempty"` + inputSchemaSource *sources.Source + outputSchemaSource *sources.Source } type NormalizedSandboxSpec struct { @@ -304,30 +309,42 @@ func normalizeAgent(name string, agent AgentSpec, options NormalizeOptions, proj if err != nil { return NormalizedAgentSpec{}, err } + inputSchema, inputSchemaSource, err := normalizeJSONSchemaSource(joinPath("agents", name)+".input_schema", agent.InputSchema, options) + if err != nil { + return NormalizedAgentSpec{}, err + } + outputSchema, outputSchemaSource, err := normalizeJSONSchemaSource(joinPath("agents", name)+".output_schema", agent.OutputSchema, options) + if err != nil { + return NormalizedAgentSpec{}, err + } capsetIDs := normalizeStringList(agent.CapsetIDs) if err := validateAgentCapsetReferences(joinPath("agents", name)+".capset_ids", capsetIDs, project.OctoBusServers); err != nil { return NormalizedAgentSpec{}, err } return NormalizedAgentSpec{ - Name: name, - Enabled: enabled, - DisplayName: strings.TrimSpace(agent.DisplayName), - Description: strings.TrimSpace(agent.Description), - Provider: strings.TrimSpace(agent.Provider), - Model: model, - SystemPrompt: agent.SystemPrompt, - Image: strings.TrimSpace(agent.Image), - Build: build, - Driver: driver, - Env: env, - MCPServers: agentMCPServers, - CapsetIDs: capsetIDs, - Skills: skills, - Volumes: volumes, - Workspace: workspace, - Sandbox: sandbox, - Scheduler: scheduler, - Jupyter: jupyter, + Name: name, + Enabled: enabled, + DisplayName: strings.TrimSpace(agent.DisplayName), + Description: strings.TrimSpace(agent.Description), + InputSchema: inputSchema, + OutputSchema: outputSchema, + inputSchemaSource: inputSchemaSource, + outputSchemaSource: outputSchemaSource, + Provider: strings.TrimSpace(agent.Provider), + Model: model, + SystemPrompt: agent.SystemPrompt, + Image: strings.TrimSpace(agent.Image), + Build: build, + Driver: driver, + Env: env, + MCPServers: agentMCPServers, + CapsetIDs: capsetIDs, + Skills: skills, + Volumes: volumes, + Workspace: workspace, + Sandbox: sandbox, + Scheduler: scheduler, + Jupyter: jupyter, }, nil } diff --git a/pkg/compose/output.go b/pkg/compose/output.go index 79c2f5fb4..f6b45667c 100644 --- a/pkg/compose/output.go +++ b/pkg/compose/output.go @@ -50,6 +50,8 @@ type orderedAgentSpec struct { Enabled bool `yaml:"enabled" json:"enabled"` DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` + InputSchema *JSONSchema `yaml:"input_schema,omitempty" json:"input_schema,omitempty"` + OutputSchema *JSONSchema `yaml:"output_schema,omitempty" json:"output_schema,omitempty"` Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` Model string `yaml:"model,omitempty" json:"model,omitempty"` SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"` @@ -130,6 +132,12 @@ func (s *NormalizedProjectSpec) ValidateResolvedScriptURLs() error { return nil } for _, agent := range s.Agents { + if agent.inputSchemaSource != nil { + return &ValidationError{Path: joinPath("agents", agent.Name) + ".input_schema", Message: "JSON Schema source is unresolved"} + } + if agent.outputSchemaSource != nil { + return &ValidationError{Path: joinPath("agents", agent.Name) + ".output_schema", Message: "JSON Schema source is unresolved"} + } if agent.Scheduler.hasUnresolvedScriptSource() { return &ValidationError{ Path: joinPath("agents", agent.Name) + ".scheduler.script", @@ -153,6 +161,8 @@ func (s *NormalizedProjectSpec) ordered(redactSecrets bool) orderedProjectSpec { Enabled: agent.Enabled, DisplayName: agent.DisplayName, Description: agent.Description, + InputSchema: cloneJSONSchema(agent.InputSchema), + OutputSchema: cloneJSONSchema(agent.OutputSchema), Provider: agent.Provider, Model: agent.Model, SystemPrompt: agent.SystemPrompt, @@ -195,11 +205,13 @@ func (s *NormalizedProjectSpec) clone(redactSecrets bool) *NormalizedProjectSpec Volumes: volumeMapFromOrdered(ordered.Volumes), } for _, agent := range ordered.Agents { - cloned.Agents = append(cloned.Agents, NormalizedAgentSpec{ + clonedAgent := NormalizedAgentSpec{ Name: agent.Name, Enabled: agent.Enabled, DisplayName: agent.DisplayName, Description: agent.Description, + InputSchema: cloneJSONSchema(agent.InputSchema), + OutputSchema: cloneJSONSchema(agent.OutputSchema), Provider: agent.Provider, Model: agent.Model, SystemPrompt: agent.SystemPrompt, @@ -215,11 +227,35 @@ func (s *NormalizedProjectSpec) clone(redactSecrets bool) *NormalizedProjectSpec Sandbox: agent.Sandbox, Scheduler: agent.Scheduler, Jupyter: agent.Jupyter, - }) + } + for i := range s.Agents { + if s.Agents[i].Name == agent.Name { + clonedAgent.inputSchemaSource = cloneSource(s.Agents[i].inputSchemaSource) + clonedAgent.outputSchemaSource = cloneSource(s.Agents[i].outputSchemaSource) + break + } + } + cloned.Agents = append(cloned.Agents, clonedAgent) } return cloned } +func cloneSource(source *sources.Source) *sources.Source { + if source == nil { + return nil + } + cloned := *source + return &cloned +} + +func cloneJSONSchema(schema *JSONSchema) *JSONSchema { + if schema == nil { + return nil + } + cloned := JSONSchema(slices.Clone([]byte(*schema))) + return &cloned +} + func orderedWorkspaces(values map[string]WorkspaceSpec, redactSecrets bool) []orderedNamedWorkspace { if len(values) == 0 { return nil diff --git a/pkg/compose/spec.go b/pkg/compose/spec.go index 93fb99cab..9e77495e5 100644 --- a/pkg/compose/spec.go +++ b/pkg/compose/spec.go @@ -54,6 +54,8 @@ type AgentSpec struct { Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` + InputSchema JSONSchemaSource `yaml:"input_schema,omitempty" json:"input_schema,omitempty"` + OutputSchema JSONSchemaSource `yaml:"output_schema,omitempty" json:"output_schema,omitempty"` Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` Model string `yaml:"model,omitempty" json:"model,omitempty"` SystemPrompt string `yaml:"system_prompt,omitempty" json:"system_prompt,omitempty"` @@ -517,6 +519,8 @@ func validateAgent(node *yaml.Node, path string) error { "enabled": validateBool, "display_name": validateScalar, "description": validateScalar, + "input_schema": validateJSONSchemaSource, + "output_schema": validateJSONSchemaSource, "provider": validateScalar, "model": validateScalar, "system_prompt": validateScalar, diff --git a/pkg/compose/testdata/compat/contract-v2608.1.0.json b/pkg/compose/testdata/compat/contract-v2608.1.0.json index d9d6a5a69..98f5f6b5a 100644 --- a/pkg/compose/testdata/compat/contract-v2608.1.0.json +++ b/pkg/compose/testdata/compat/contract-v2608.1.0.json @@ -147,6 +147,11 @@ "go_type": "string", "optional": true }, + { + "path": "agents.*.input_schema", + "go_type": "mapping\u003ccompose.JSONSchemaSource\u003e", + "optional": true + }, { "path": "agents.*.jupyter", "go_type": "optional\u003cmapping\u003ccompose.JupyterSpec\u003e\u003e", @@ -232,6 +237,11 @@ "go_type": "string", "optional": true }, + { + "path": "agents.*.output_schema", + "go_type": "mapping\u003ccompose.JSONSchemaSource\u003e", + "optional": true + }, { "path": "agents.*.provider", "go_type": "string", diff --git a/pkg/projects/records.go b/pkg/projects/records.go index 5aa4aef3e..8e45cf0ae 100644 --- a/pkg/projects/records.go +++ b/pkg/projects/records.go @@ -142,6 +142,8 @@ func NewAgentDefinitionFromSpec(project domain.ProjectRecord, revision int64, ag } type agentDefinitionConfig struct { + InputSchema *compose.JSONSchema `json:"input_schema,omitempty"` + OutputSchema *compose.JSONSchema `json:"output_schema,omitempty"` Jupyter *compose.JupyterSpec `json:"jupyter,omitempty"` Sandbox *compose.NormalizedSandboxSpec `json:"sandbox,omitempty"` MCPServers map[string]compose.NormalizedMCPServerSpec `json:"mcp_servers,omitempty"` @@ -157,13 +159,15 @@ type agentDefinitionConfig struct { func agentDefinitionConfigJSON(agent compose.NormalizedAgentSpec, projectMCPServers map[string]compose.NormalizedMCPServerSpec, projectOctoBusServers map[string]compose.NormalizedOctoBusServerSpec) (string, error) { payload := agentDefinitionConfig{ + InputSchema: agent.InputSchema, + OutputSchema: agent.OutputSchema, Jupyter: agent.Jupyter, Sandbox: agent.Sandbox, MCPServers: selectedAgentMCPServers(agent, projectMCPServers), OctoBusServers: selectedAgentOctoBusServers(agent, projectOctoBusServers), Workspace: agent.Workspace, } - if payload.Jupyter == nil && payload.Sandbox == nil && payload.Workspace == nil && len(payload.MCPServers) == 0 && len(payload.OctoBusServers) == 0 { + if payload.InputSchema == nil && payload.OutputSchema == nil && payload.Jupyter == nil && payload.Sandbox == nil && payload.Workspace == nil && len(payload.MCPServers) == 0 && len(payload.OctoBusServers) == 0 { return "{}", nil } data, err := MarshalCanonicalJSON(payload) diff --git a/pkg/projects/records_test.go b/pkg/projects/records_test.go index 1bd59cacd..1088df606 100644 --- a/pkg/projects/records_test.go +++ b/pkg/projects/records_test.go @@ -2,6 +2,7 @@ package projects import ( "encoding/json" + "strings" "testing" "agent-compose/pkg/capabilities" @@ -122,6 +123,19 @@ func TestProjectRecordsCarryVolumeMountSpecs(t *testing.T) { } } +func TestAgentSchemasFlowIntoManagedAgentConfig(t *testing.T) { + input := compose.JSONSchema(`{"type":"object"}`) + output := compose.JSONSchema(`{"type":"string"}`) + agent := compose.NormalizedAgentSpec{Name: "worker", Enabled: true, InputSchema: &input, OutputSchema: &output} + definition, err := NewAgentDefinitionFromSpec(domain.ProjectRecord{ID: "project-1"}, 1, agent, AgentDefinitionProjectRefs{}) + if err != nil { + t.Fatalf("NewAgentDefinitionFromSpec returned error: %v", err) + } + if !strings.Contains(definition.ConfigJSON, `"input_schema":{"type":"object"}`) || !strings.Contains(definition.ConfigJSON, `"output_schema":{"type":"string"}`) { + t.Fatalf("config JSON = %s", definition.ConfigJSON) + } +} + func TestSchedulerConcurrencyPolicyFlowsIntoManagedScheduler(t *testing.T) { for _, test := range []struct { name string diff --git a/proto/agentcompose/v2/agentcompose.proto b/proto/agentcompose/v2/agentcompose.proto index df17b2e6e..19c0bea60 100644 --- a/proto/agentcompose/v2/agentcompose.proto +++ b/proto/agentcompose/v2/agentcompose.proto @@ -885,6 +885,10 @@ message AgentSpec { string display_name = 17; string description = 18; SandboxSpec sandbox = 19; + // Optional JSON Schema describing inputs accepted by this agent. + string input_schema_json = 20; + // Optional JSON Schema describing outputs produced by this agent. + string output_schema_json = 21; } message SandboxSpec { From cff2f82e20373f4e85e7b4011616bb0a8fce2e0c Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 16:28:37 +0800 Subject: [PATCH 2/6] fix(compose): preserve schema round trips --- .../api/project_spec_mapper_schema_test.go | 2 +- pkg/agentcompose/api/project_yaml_shape.go | 32 ++++++++++++- pkg/compose/json_schema_source.go | 48 +++++++++++++++++-- pkg/compose/json_schema_source_test.go | 36 ++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/pkg/agentcompose/api/project_spec_mapper_schema_test.go b/pkg/agentcompose/api/project_spec_mapper_schema_test.go index 6c557f7c4..912f8d2bc 100644 --- a/pkg/agentcompose/api/project_spec_mapper_schema_test.go +++ b/pkg/agentcompose/api/project_spec_mapper_schema_test.go @@ -23,7 +23,7 @@ func TestAgentSpecsToProtoIncludesJSONSchemas(t *testing.T) { } func TestProjectSpecSchemaProtoRoundTripPreservesHash(t *testing.T) { - parsedOriginal, err := compose.Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: object\n properties:\n count:\n type: integer\n default: 3\n output_schema:\n type: string\n")) + parsedOriginal, err := compose.Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: object\n provider: custom-keyword\n properties:\n count:\n type: integer\n default: 9223372036854775807\n output_schema:\n type: string\n")) if err != nil { t.Fatal(err) } diff --git a/pkg/agentcompose/api/project_yaml_shape.go b/pkg/agentcompose/api/project_yaml_shape.go index 6ae048358..14a2e7846 100644 --- a/pkg/agentcompose/api/project_yaml_shape.go +++ b/pkg/agentcompose/api/project_yaml_shape.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "fmt" "strings" @@ -8,6 +9,8 @@ import ( "agent-compose/pkg/capabilities" "agent-compose/pkg/compose" agentcomposev2 "agent-compose/proto/agentcompose/v2" + + "gopkg.in/yaml.v3" ) func ProjectSpecYAMLShape(spec *agentcomposev2.ProjectSpec) (map[string]any, []*agentcomposev2.ProjectValidationIssue) { @@ -188,17 +191,42 @@ func agentJSONSchemaYAMLValue(path, raw string) (any, *agentcomposev2.ProjectVal return nil, nil } var value any - if err := json.Unmarshal([]byte(raw), &value); err != nil { + decoder := json.NewDecoder(bytes.NewReader([]byte(raw))) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { return nil, ProjectValidationIssue(path, "must contain valid JSON") } switch value.(type) { case map[string]any, bool: - return value, nil + return jsonNumbersForProjectYAML(value), nil default: return nil, ProjectValidationIssue(path, "must contain a JSON Schema object or boolean") } } +func jsonNumbersForProjectYAML(value any) any { + switch value := value.(type) { + case json.Number: + tag := "!!int" + if strings.ContainsAny(value.String(), ".eE") { + tag = "!!float" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value.String()} + case map[string]any: + for key, item := range value { + value[key] = jsonNumbersForProjectYAML(item) + } + return value + case []any: + for i := range value { + value[i] = jsonNumbersForProjectYAML(value[i]) + } + return value + default: + return value + } +} + func MCPServerYAMLMap(path string, mcps []*agentcomposev2.MCPServerSpec) (map[string]any, []*agentcomposev2.ProjectValidationIssue) { values := make(map[string]any, len(mcps)) for i, mcp := range mcps { diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go index fe14ac1dd..8693bca76 100644 --- a/pkg/compose/json_schema_source.go +++ b/pkg/compose/json_schema_source.go @@ -22,7 +22,7 @@ type JSONSchemaSource struct { func (s JSONSchemaSource) IsZero() bool { return s.Inline == nil && !s.Source.HasContent() } func (s *JSONSchemaSource) UnmarshalYAML(value *yaml.Node) error { - if value.Kind == yaml.MappingNode && mappingHasKey(value, "provider") { + if value.Kind == yaml.MappingNode && mappingHasSourceProvider(value) { var source sources.Source if err := value.Decode(&source); err != nil { return err @@ -77,11 +77,36 @@ func (s *JSONSchema) UnmarshalJSON(data []byte) error { func (s JSONSchema) MarshalYAML() (any, error) { return s.yamlValue() } func (s JSONSchema) yamlValue() (any, error) { + decoder := json.NewDecoder(bytes.NewReader(s)) + decoder.UseNumber() var value any - if err := json.Unmarshal(s, &value); err != nil { + if err := decoder.Decode(&value); err != nil { return nil, err } - return value, nil + return jsonNumbersForYAML(value), nil +} + +func jsonNumbersForYAML(value any) any { + switch value := value.(type) { + case json.Number: + tag := "!!int" + if bytes.ContainsAny([]byte(value), ".eE") { + tag = "!!float" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value.String()} + case map[string]any: + for key, item := range value { + value[key] = jsonNumbersForYAML(item) + } + return value + case []any: + for i := range value { + value[i] = jsonNumbersForYAML(value[i]) + } + return value + default: + return value + } } func validateJSONSchemaDocument(data []byte) error { @@ -90,8 +115,10 @@ func validateJSONSchemaDocument(data []byte) error { } func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() var value any - if err := json.Unmarshal(data, &value); err != nil { + if err := decoder.Decode(&value); err != nil { return nil, fmt.Errorf("invalid JSON Schema: %w", err) } switch value.(type) { @@ -115,8 +142,19 @@ func mappingHasKey(node *yaml.Node, key string) bool { return false } +func mappingHasSourceProvider(node *yaml.Node) bool { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value != "provider" { + continue + } + value := node.Content[i+1] + return value.Kind == yaml.ScalarNode && (value.Value == "file" || value.Value == "http" || value.Value == "git") + } + return false +} + func validateJSONSchemaSource(node *yaml.Node, path string) error { - if node.Kind == yaml.MappingNode && mappingHasKey(node, "provider") { + if node.Kind == yaml.MappingNode && mappingHasSourceProvider(node) { return validateMapping(node, path, sourceFieldValidators(nil)) } if node.Kind == yaml.MappingNode || (node.Kind == yaml.ScalarNode && node.Tag == "!!bool") { diff --git a/pkg/compose/json_schema_source_test.go b/pkg/compose/json_schema_source_test.go index 7720a6995..a78d7c750 100644 --- a/pkg/compose/json_schema_source_test.go +++ b/pkg/compose/json_schema_source_test.go @@ -92,3 +92,39 @@ func TestAgentJSONSchemaRejectsNonSchemaDocument(t *testing.T) { t.Fatalf("Parse error = %v", err) } } + +func TestAgentJSONSchemaPreservesLargeNumbers(t *testing.T) { + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: integer\n maximum: 9223372036854775807\n")) + if err != nil { + t.Fatal(err) + } + normalized, err := Normalize(spec, NormalizeOptions{}) + if err != nil { + t.Fatal(err) + } + if got := string(*normalized.Agents[0].InputSchema); !strings.Contains(got, "9223372036854775807") { + t.Fatalf("schema = %s", got) + } +} + +func TestAgentJSONSchemaProviderKeywordRoundTripsAsInlineSchema(t *testing.T) { + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: object\n provider: custom-keyword\n")) + if err != nil { + t.Fatal(err) + } + normalized, err := Normalize(spec, NormalizeOptions{}) + if err != nil { + t.Fatal(err) + } + data, err := normalized.MarshalCanonicalJSON(false) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseCanonicalJSON(data) + if err != nil { + t.Fatal(err) + } + if parsed.Agents[0].InputSchema == nil { + t.Fatal("input schema was lost") + } +} From f2c16bb05cee18e69f0f544ec559c8696cd329ef Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 16:30:01 +0800 Subject: [PATCH 3/6] docs(compose): clarify schema provider keywords --- docs/pages/agent-compose-yaml-manual.md | 2 +- docs/pages/zh-CN/agent-compose-yaml-manual.md | 2 +- pkg/compose/json_schema_source.go | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pages/agent-compose-yaml-manual.md b/docs/pages/agent-compose-yaml-manual.md index 10de45eae..756f2c44a 100644 --- a/docs/pages/agent-compose-yaml-manual.md +++ b/docs/pages/agent-compose-yaml-manual.md @@ -464,7 +464,7 @@ agents: path: ./schemas/research-result.schema.json ``` -The source form is the same flat descriptor accepted by `scheduler.script` (`file`, `http`, or `git`). Relative file paths resolve from the compose file directory. Source content is resolved and stored as a snapshot when the project is applied; it must contain a JSON object or boolean schema. A mapping containing `provider` is interpreted as a source descriptor, so an inline schema that needs a custom top-level `provider` keyword should place that schema in a referenced file. +The source form is the same flat descriptor accepted by `scheduler.script` (`file`, `http`, or `git`). Relative file paths resolve from the compose file directory. Source content is resolved and stored as a snapshot when the project is applied; it must contain a JSON object or boolean schema. A mapping whose top-level `provider` value is `file`, `http`, or `git` is interpreted as a source descriptor; other `provider` values remain available as custom inline-schema keywords. ### `enabled`, `provider`, `model`, and `system_prompt` diff --git a/docs/pages/zh-CN/agent-compose-yaml-manual.md b/docs/pages/zh-CN/agent-compose-yaml-manual.md index 1ec710da1..d66dce39c 100644 --- a/docs/pages/zh-CN/agent-compose-yaml-manual.md +++ b/docs/pages/zh-CN/agent-compose-yaml-manual.md @@ -465,7 +465,7 @@ agents: path: ./schemas/research-result.schema.json ``` -source 形式与 `scheduler.script` 接受的扁平 descriptor 一致(`file`、`http` 或 `git`)。相对文件路径以 compose 文件所在目录为基准。应用项目时会解析内容并保存快照,内容必须是 JSON object 或 boolean schema。包含 `provider` 的 mapping 会被解释为 source descriptor;如果内联 schema 需要自定义的顶层 `provider` 关键字,请改用文件引用。 +source 形式与 `scheduler.script` 接受的扁平 descriptor 一致(`file`、`http` 或 `git`)。相对文件路径以 compose 文件所在目录为基准。应用项目时会解析内容并保存快照,内容必须是 JSON object 或 boolean schema。顶层 `provider` 值为 `file`、`http` 或 `git` 的 mapping 会被解释为 source descriptor;其他 `provider` 值仍可作为内联 schema 的自定义关键字。 ### `enabled`、`provider`、`model` 和 `system_prompt` diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go index 8693bca76..c24ea17ef 100644 --- a/pkg/compose/json_schema_source.go +++ b/pkg/compose/json_schema_source.go @@ -12,8 +12,9 @@ import ( ) // JSONSchemaSource accepts either an inline JSON Schema or the same source -// descriptor shape used by scheduler.script. A mapping containing provider is -// treated as a source descriptor; every other mapping and boolean is inline. +// descriptor shape used by scheduler.script. A mapping whose provider is file, +// http, or git is treated as a source descriptor; every other mapping and +// boolean is inline. type JSONSchemaSource struct { Inline *JSONSchema Source sources.Source From 2d270356409636d940d6b96fcb67f3a9986fa35a Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 17:26:47 +0800 Subject: [PATCH 4/6] fix(compose): keep schema parsing strict and stable --- pkg/compose/json_schema_source.go | 36 +++++++++++++++++++------- pkg/compose/json_schema_source_test.go | 7 +++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go index c24ea17ef..ae414c51c 100644 --- a/pkg/compose/json_schema_source.go +++ b/pkg/compose/json_schema_source.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "strings" "agent-compose/pkg/sources" @@ -122,6 +124,10 @@ func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { if err := decoder.Decode(&value); err != nil { return nil, fmt.Errorf("invalid JSON Schema: %w", err) } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("invalid JSON Schema: unexpected trailing data") + } + value = canonicalizeSchemaNumbers(value) switch value.(type) { case map[string]any, bool: canonical, err := json.Marshal(value) @@ -134,15 +140,6 @@ func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { } } -func mappingHasKey(node *yaml.Node, key string) bool { - for i := 0; i+1 < len(node.Content); i += 2 { - if node.Content[i].Value == key { - return true - } - } - return false -} - func mappingHasSourceProvider(node *yaml.Node) bool { for i := 0; i+1 < len(node.Content); i += 2 { if node.Content[i].Value != "provider" { @@ -154,6 +151,27 @@ func mappingHasSourceProvider(node *yaml.Node) bool { return false } +func canonicalizeSchemaNumbers(value any) any { + switch value := value.(type) { + case json.Number: + if strings.ContainsAny(value.String(), ".eE") { + if number, err := value.Float64(); err == nil { + return number + } + } + return value + case map[string]any: + for key, item := range value { + value[key] = canonicalizeSchemaNumbers(item) + } + case []any: + for i, item := range value { + value[i] = canonicalizeSchemaNumbers(item) + } + } + return value +} + func validateJSONSchemaSource(node *yaml.Node, path string) error { if node.Kind == yaml.MappingNode && mappingHasSourceProvider(node) { return validateMapping(node, path, sourceFieldValidators(nil)) diff --git a/pkg/compose/json_schema_source_test.go b/pkg/compose/json_schema_source_test.go index a78d7c750..94f9079d1 100644 --- a/pkg/compose/json_schema_source_test.go +++ b/pkg/compose/json_schema_source_test.go @@ -128,3 +128,10 @@ func TestAgentJSONSchemaProviderKeywordRoundTripsAsInlineSchema(t *testing.T) { t.Fatal("input schema was lost") } } + +func TestAgentJSONSchemaRejectsTrailingJSON(t *testing.T) { + var schema JSONSchema + if err := schema.UnmarshalJSON([]byte(`{"type":"object"} trailing`)); err == nil { + t.Fatal("expected trailing JSON to be rejected") + } +} From ca325aacb58d755ee3acbf6e55b90ef57fc9fd3e Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 17:37:15 +0800 Subject: [PATCH 5/6] fix(compose): preserve JSON schema number literals --- pkg/compose/json_schema_source.go | 23 ----------------------- pkg/compose/json_schema_source_test.go | 12 ++++++++++++ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go index ae414c51c..8953c4563 100644 --- a/pkg/compose/json_schema_source.go +++ b/pkg/compose/json_schema_source.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "strings" "agent-compose/pkg/sources" @@ -127,7 +126,6 @@ func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { if err := decoder.Decode(&struct{}{}); err != io.EOF { return nil, fmt.Errorf("invalid JSON Schema: unexpected trailing data") } - value = canonicalizeSchemaNumbers(value) switch value.(type) { case map[string]any, bool: canonical, err := json.Marshal(value) @@ -151,27 +149,6 @@ func mappingHasSourceProvider(node *yaml.Node) bool { return false } -func canonicalizeSchemaNumbers(value any) any { - switch value := value.(type) { - case json.Number: - if strings.ContainsAny(value.String(), ".eE") { - if number, err := value.Float64(); err == nil { - return number - } - } - return value - case map[string]any: - for key, item := range value { - value[key] = canonicalizeSchemaNumbers(item) - } - case []any: - for i, item := range value { - value[i] = canonicalizeSchemaNumbers(item) - } - } - return value -} - func validateJSONSchemaSource(node *yaml.Node, path string) error { if node.Kind == yaml.MappingNode && mappingHasSourceProvider(node) { return validateMapping(node, path, sourceFieldValidators(nil)) diff --git a/pkg/compose/json_schema_source_test.go b/pkg/compose/json_schema_source_test.go index 94f9079d1..0f8ba8409 100644 --- a/pkg/compose/json_schema_source_test.go +++ b/pkg/compose/json_schema_source_test.go @@ -135,3 +135,15 @@ func TestAgentJSONSchemaRejectsTrailingJSON(t *testing.T) { t.Fatal("expected trailing JSON to be rejected") } } + +func TestAgentJSONSchemaPreservesJSONNumberLiterals(t *testing.T) { + var schema JSONSchema + const raw = `{"type":"number","minimum":0.12345678901234567890123,"maximum":1e-3}` + if err := schema.UnmarshalJSON([]byte(raw)); err != nil { + t.Fatal(err) + } + got := string(schema) + if !strings.Contains(got, "0.12345678901234567890123") || !strings.Contains(got, "1e-3") { + t.Fatalf("schema = %s", got) + } +} From a0fe80b05d8a2a742995602aa13148e074738b6a Mon Sep 17 00:00:00 2001 From: kingfs Date: Fri, 21 Aug 2026 20:00:59 +0800 Subject: [PATCH 6/6] fix(compose): compile agent JSON schemas --- docs/pages/agent-compose-yaml-manual.md | 2 +- docs/pages/zh-CN/agent-compose-yaml-manual.md | 2 +- go.mod | 1 + go.sum | 4 + pkg/compose/json_schema_source.go | 81 +++++++++++++++---- pkg/compose/json_schema_source_test.go | 40 +++++++++ 6 files changed, 113 insertions(+), 17 deletions(-) diff --git a/docs/pages/agent-compose-yaml-manual.md b/docs/pages/agent-compose-yaml-manual.md index 756f2c44a..5d19fbdf6 100644 --- a/docs/pages/agent-compose-yaml-manual.md +++ b/docs/pages/agent-compose-yaml-manual.md @@ -464,7 +464,7 @@ agents: path: ./schemas/research-result.schema.json ``` -The source form is the same flat descriptor accepted by `scheduler.script` (`file`, `http`, or `git`). Relative file paths resolve from the compose file directory. Source content is resolved and stored as a snapshot when the project is applied; it must contain a JSON object or boolean schema. A mapping whose top-level `provider` value is `file`, `http`, or `git` is interpreted as a source descriptor; other `provider` values remain available as custom inline-schema keywords. +The source form is the same flat descriptor accepted by `scheduler.script` (`file`, `http`, or `git`). Relative file paths resolve from the compose file directory. Source content is resolved, compiled as JSON Schema, and stored as a snapshot when the project is applied; it must contain a JSON object or boolean schema. References within the same schema document are supported, while external `$ref` resources are rejected so applying a stored snapshot never performs implicit filesystem or network access. A mapping whose top-level `provider` value is `file`, `http`, or `git` is interpreted as a source descriptor; other `provider` values remain available as custom inline-schema keywords. ### `enabled`, `provider`, `model`, and `system_prompt` diff --git a/docs/pages/zh-CN/agent-compose-yaml-manual.md b/docs/pages/zh-CN/agent-compose-yaml-manual.md index d66dce39c..af0572df7 100644 --- a/docs/pages/zh-CN/agent-compose-yaml-manual.md +++ b/docs/pages/zh-CN/agent-compose-yaml-manual.md @@ -465,7 +465,7 @@ agents: path: ./schemas/research-result.schema.json ``` -source 形式与 `scheduler.script` 接受的扁平 descriptor 一致(`file`、`http` 或 `git`)。相对文件路径以 compose 文件所在目录为基准。应用项目时会解析内容并保存快照,内容必须是 JSON object 或 boolean schema。顶层 `provider` 值为 `file`、`http` 或 `git` 的 mapping 会被解释为 source descriptor;其他 `provider` 值仍可作为内联 schema 的自定义关键字。 +source 形式与 `scheduler.script` 接受的扁平 descriptor 一致(`file`、`http` 或 `git`)。相对文件路径以 compose 文件所在目录为基准。应用项目时会解析内容、编译为 JSON Schema 并保存快照,内容必须是 JSON object 或 boolean schema。支持同一 schema 文档内的引用,但会拒绝外部 `$ref` 资源,确保应用已保存的快照时不会隐式访问文件系统或网络。顶层 `provider` 值为 `file`、`http` 或 `git` 的 mapping 会被解释为 source descriptor;其他 `provider` 值仍可作为内联 schema 的自定义关键字。 ### `enabled`、`provider`、`model` 和 `system_prompt` diff --git a/go.mod b/go.mod index 51fd6928e..5d8c1694b 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/samber/do/v2 v2.0.0 github.com/samber/mo v1.16.0 github.com/samber/oops v1.21.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 github.com/superradcompany/microsandbox/sdk/go v0.6.8 diff --git a/go.sum b/go.sum index ae50c305f..76d44a099 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -138,6 +140,8 @@ github.com/samber/mo v1.16.0 h1:qpEPCI63ou6wXlsNDMLE0IIN8A+devbGX/K1xdgr4b4= github.com/samber/mo v1.16.0/go.mod h1:DlgzJ4SYhOh41nP1L9kh9rDNERuf8IqWSAs+gj2Vxag= github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= diff --git a/pkg/compose/json_schema_source.go b/pkg/compose/json_schema_source.go index 8953c4563..1c3703e2c 100644 --- a/pkg/compose/json_schema_source.go +++ b/pkg/compose/json_schema_source.go @@ -5,10 +5,10 @@ import ( "context" "encoding/json" "fmt" - "io" "agent-compose/pkg/sources" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" "gopkg.in/yaml.v3" ) @@ -38,8 +38,8 @@ func (s *JSONSchemaSource) UnmarshalYAML(value *yaml.Node) error { if value.Tag == "!!null" { return fmt.Errorf("JSON Schema must not be null") } - var decoded any - if err := value.Decode(&decoded); err != nil { + decoded, err := jsonSchemaYAMLValue(value) + if err != nil { return err } data, err := json.Marshal(decoded) @@ -117,25 +117,76 @@ func validateJSONSchemaDocument(data []byte) error { } func canonicalJSONSchemaDocument(data []byte) ([]byte, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - var value any - if err := decoder.Decode(&value); err != nil { + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { return nil, fmt.Errorf("invalid JSON Schema: %w", err) } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return nil, fmt.Errorf("invalid JSON Schema: unexpected trailing data") - } switch value.(type) { case map[string]any, bool: - canonical, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode JSON Schema: %w", err) - } - return canonical, nil default: return nil, fmt.Errorf("JSON Schema must be an object or boolean") } + compiler := jsonschema.NewCompiler() + compiler.UseLoader(rejectJSONSchemaResourceLoader{}) + if err := compiler.AddResource("schema.json", value); err != nil { + return nil, fmt.Errorf("load JSON Schema: %w", err) + } + if _, err := compiler.Compile("schema.json"); err != nil { + return nil, fmt.Errorf("invalid JSON Schema: %w", err) + } + canonical, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode JSON Schema: %w", err) + } + return canonical, nil +} + +type rejectJSONSchemaResourceLoader struct{} + +func (rejectJSONSchemaResourceLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("external JSON Schema resource %q is not supported", url) +} + +func jsonSchemaYAMLValue(node *yaml.Node) (any, error) { + switch node.Kind { + case yaml.MappingNode: + value := make(map[string]any, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { + return nil, fmt.Errorf("JSON Schema object keys must be strings") + } + item, err := jsonSchemaYAMLValue(node.Content[i+1]) + if err != nil { + return nil, err + } + value[key.Value] = item + } + return value, nil + case yaml.SequenceNode: + value := make([]any, len(node.Content)) + for i, item := range node.Content { + decoded, err := jsonSchemaYAMLValue(item) + if err != nil { + return nil, err + } + value[i] = decoded + } + return value, nil + case yaml.AliasNode: + return jsonSchemaYAMLValue(node.Alias) + case yaml.ScalarNode: + if (node.Tag == "!!int" || node.Tag == "!!float") && json.Valid([]byte(node.Value)) { + return json.Number(node.Value), nil + } + var value any + if err := node.Decode(&value); err != nil { + return nil, err + } + return value, nil + default: + return nil, fmt.Errorf("unsupported YAML node in JSON Schema") + } } func mappingHasSourceProvider(node *yaml.Node) bool { diff --git a/pkg/compose/json_schema_source_test.go b/pkg/compose/json_schema_source_test.go index 0f8ba8409..5f5018c03 100644 --- a/pkg/compose/json_schema_source_test.go +++ b/pkg/compose/json_schema_source_test.go @@ -147,3 +147,43 @@ func TestAgentJSONSchemaPreservesJSONNumberLiterals(t *testing.T) { t.Fatalf("schema = %s", got) } } + +func TestAgentJSONSchemaInlineYAMLPreservesNumberLiterals(t *testing.T) { + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n type: number\n minimum: 0.12345678901234567890123\n maximum: 1e-3\n")) + if err != nil { + t.Fatal(err) + } + normalized, err := Normalize(spec, NormalizeOptions{}) + if err != nil { + t.Fatal(err) + } + got := string(*normalized.Agents[0].InputSchema) + if !strings.Contains(got, "0.12345678901234567890123") || !strings.Contains(got, "1e-3") { + t.Fatalf("schema = %s", got) + } +} + +func TestAgentJSONSchemaRejectsInvalidSchemaKeywords(t *testing.T) { + for _, schema := range []string{ + "name: schemas\nagents:\n worker:\n input_schema:\n type: unknown\n", + "name: schemas\nagents:\n worker:\n input_schema:\n type: string\n pattern: '[unterminated'\n", + } { + spec, err := Parse([]byte(schema)) + if err != nil { + t.Fatal(err) + } + if _, err := Normalize(spec, NormalizeOptions{}); err == nil { + t.Fatalf("expected invalid schema to be rejected: %s", schema) + } + } +} + +func TestAgentJSONSchemaRejectsExternalReferences(t *testing.T) { + spec, err := Parse([]byte("name: schemas\nagents:\n worker:\n input_schema:\n $ref: https://example.test/schema.json\n")) + if err != nil { + t.Fatal(err) + } + if _, err := Normalize(spec, NormalizeOptions{}); err == nil || !strings.Contains(err.Error(), "external JSON Schema resource") { + t.Fatalf("Normalize error = %v", err) + } +}