Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/agent-compose/cli_compose_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions docs/pages/agent-compose-yaml-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<llm-provider-id>/<model-name>`. Supports `${NAME}` interpolation. |
| `system_prompt` | string | Empty | Additional system instructions; YAML block scalars are recommended for multiline text. |
Expand All @@ -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, 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`

```yaml
Expand Down
24 changes: 24 additions & 0 deletions docs/pages/zh-CN/agent-compose-yaml-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 要求使用 `<llm-provider-id>/<model-name>`;支持 `${NAME}` 插值。 |
| `system_prompt` | string | 空 | 附加的系统提示,适合使用 YAML `|` 多行标量。 |
Expand All @@ -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 Schema 并保存快照,内容必须是 JSON object 或 boolean schema。支持同一 schema 文档内的引用,但会拒绝外部 `$ref` 资源,确保应用已保存的快照时不会隐式访问文件系统或网络。顶层 `provider` 值为 `file`、`http` 或 `git` 的 mapping 会被解释为 source descriptor;其他 `provider` 值仍可作为内联 schema 的自定义关键字。

### `enabled`、`provider`、`model` 和 `system_prompt`

```yaml
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
47 changes: 28 additions & 19 deletions pkg/agentcompose/api/project_spec_mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions pkg/agentcompose/api/project_spec_mapper_schema_test.go
Original file line number Diff line number Diff line change
@@ -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 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)
}
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)
}
}
55 changes: 55 additions & 0 deletions pkg/agentcompose/api/project_yaml_shape.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package api

import (
"bytes"
"encoding/json"
"fmt"
"strings"

"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) {
Expand Down Expand Up @@ -111,6 +115,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()
}
Expand Down Expand Up @@ -172,6 +186,47 @@ func AgentYAMLMap(agents []*agentcomposev2.AgentSpec) (map[string]any, []*agentc
return values, nil
}

func agentJSONSchemaYAMLValue(path, raw string) (any, *agentcomposev2.ProjectValidationIssue) {
Comment thread
monkeyscan[bot] marked this conversation as resolved.
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var value any
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 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 {
Expand Down
2 changes: 2 additions & 0 deletions pkg/compose/canonical_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading