Skip to content
Draft
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
9 changes: 3 additions & 6 deletions cmd/agent-compose/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ agents:
if runCount != 0 {
t.Fatalf("daemon runner called %d times, want 0", runCount)
}
for _, want := range []string{"name: review-project", "variables:", "agents:", "network:", "mode: default", "secret: true", "********", "visible", "strict"} {
for _, want := range []string{"name: review-project", "variables:", "agents:", "secret: true", "********", "visible", "strict"} {
if !strings.Contains(stdout, want) {
t.Fatalf("config YAML missing %q:\n%s", want, stdout)
}
Expand Down Expand Up @@ -708,14 +708,11 @@ agents:
Name string `json:"name"`
} `json:"driver"`
} `json:"agents"`
Network struct {
Mode string `json:"mode"`
} `json:"network"`
}
if err := json.Unmarshal([]byte(stdout), &decoded); err != nil {
t.Fatalf("config --json output is not JSON: %v\n%s", err, stdout)
}
if decoded.Name != "json-project" || decoded.Network.Mode != "default" {
if decoded.Name != "json-project" {
t.Fatalf("decoded config = %#v", decoded)
}
if len(decoded.Variables) != 1 || decoded.Variables[0].Name != "TOKEN" || decoded.Variables[0].Value != "********" || !decoded.Variables[0].Secret {
Expand Down Expand Up @@ -7764,7 +7761,7 @@ agents:
if err == nil {
t.Fatalf("config --quiet returned nil error, want validation error")
}
for _, want := range []string{composePath, "network.mode", "unsupported"} {
for _, want := range []string{composePath, "network", "unknown field"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not contain %q", err.Error(), want)
}
Expand Down
47 changes: 43 additions & 4 deletions docs/design/agent-compose_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,51 @@ agents:
prompt: "Review changes from the incoming event."
sandbox_policy: new

network:
mode: default
```

Docker projects can opt into isolated named bridge networks:

```yaml
name: networked-project

networks:
frontend: {}
backend:
driver: bridge

agents:
api:
networks: [frontend, backend]
expose: ["8080"]
web:
networks: [frontend]
ports: ["127.0.0.1:18080:8080"]
worker:
networks: [backend]
```

Named networks are supported only by Docker agents and currently use the
`bridge` driver. Agents can resolve peers by agent name or by the unique
`<agent>-<sandbox-short-id>` alias. An agent without an explicit attachment is
placed on an implicit project `default` network. The first attachment is the
primary network and supplies the default gateway.

`expose` writes Docker port metadata; it is not a firewall rule and does not
publish a host port. `ports` explicitly publishes TCP ports and defaults
short-form bindings to `127.0.0.1`; an omitted host port asks Docker to allocate
one dynamically. Port-only configuration without named networks continues to
use the existing single bridge.

Any effective named-network attachment adds a project bridge beyond the
daemon/sandbox baseline. This multi-network mode requires a local Linux Docker
Engine over a Unix socket and an agent-compose daemon running natively or in a
container with `network_mode: host`. The default bridge-mode Compose deployment
can still validate and store such a project, but starting its sandbox fails
before any project network or container is created. Projects without named
networks preserve the existing shared single-bridge behavior. The legacy
singular `network` field has been removed; `networks` is the only project
network configuration entry point.

The same scheduler can also declare a loader script directly with inline QJS:

```yaml
Expand Down Expand Up @@ -227,8 +268,6 @@ Normalization rules:
omitted, the default is `docker`.
- `firecracker` may appear in the schema, but current normalization returns
unsupported.
- Empty `network` or `mode: default` is accepted. Other network modes return
unsupported.
- Triggers support `cron`, `interval`, `timeout`, and `event`. Each trigger must
specify exactly one type.
- `scheduler.sandbox_policy` accepts `new` or `sticky` and defaults to `new`.
Expand Down
4 changes: 2 additions & 2 deletions docs/zh-CN/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ agent-compose down # 停止 sandbox、禁用 sche

## Compose 配置

**顶层字段:** `name`、`variables`、`agents`、`mcp_servers`、`volumes`、`network`。
**顶层字段:** `name`、`variables`、`agents`、`mcp_servers`、`volumes`、`networks`。

**agent 常用字段:** `provider`、`model`、`system_prompt`、`image`、`driver`、
`env`(scalar 或 `{ value, secret }`)、`workspace`、`scheduler`、`mcp_servers`、`skills`、`volumes`。
`env`(scalar 或 `{ value, secret }`)、`workspace`、`scheduler`、`mcp_servers`、`skills`、`volumes`、`networks`、`expose`、`ports`

为 agent 从本地路径(`provider: local`)或 Git 仓库(`provider: git`)配置 workspace:

Expand Down
41 changes: 38 additions & 3 deletions docs/zh-CN/design/agent-compose_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,46 @@ agents:
topic: git.push
prompt: "Review changes from the incoming event."

network:
mode: default
```

Docker project 可以显式启用相互隔离的命名 bridge 网络:

```yaml
name: networked-project

networks:
frontend: {}
backend:
driver: bridge

agents:
api:
networks: [frontend, backend]
expose: ["8080"]
web:
networks: [frontend]
ports: ["127.0.0.1:18080:8080"]
worker:
networks: [backend]
```

命名网络只支持 Docker agent,当前仅支持 `bridge` driver。agent 可以通过
agent 名或唯一的 `<agent>-<sandbox-short-id>` alias 解析同一网络中的 peer。
没有显式 attachment 的 agent 会加入隐式的 project `default` 网络;第一项
attachment 是 primary network,并提供默认网关。

`expose` 只写入 Docker 端口元数据,它不是防火墙规则,也不会发布宿主机端口。
`ports` 显式发布 TCP 端口,短格式默认绑定 `127.0.0.1`;省略 host port 时由
Docker 动态分配。只配置端口而不配置 named networks 时仍使用现有单 bridge。

任何有效的 named-network attachment 都会在 daemon/sandbox baseline bridge
之外增加 project bridge,因此进入多网络模式。该模式要求通过 Unix socket
访问本机 Linux Docker Engine,并要求 agent-compose daemon 是 native 进程,
或者使用 `network_mode: host` 的容器。默认 bridge-mode Compose 部署仍可校验
和保存这类 project,但启动 sandbox 时会在创建 project network/container
之前失败。未配置 named networks 的 project 保持现有共享单 bridge 行为。
旧的单数 `network` 字段已经删除;`networks` 是唯一的 project 网络配置入口。

同一个 scheduler 也可以用 inline QJS 直接声明 loader 脚本:

```yaml
Expand Down Expand Up @@ -193,7 +229,6 @@ daemon、v2 API、持久化 revision 和 loader runtime 仍只接收脚本文本
- agent map key 必须是稳定标识,输出按 agent name 排序。
- driver 采用 one-of:`boxlite`、`docker`、`microsandbox`。省略时默认 `docker`。
- `firecracker` 可出现在 schema 中,但当前规范化直接返回 unsupported。
- `network` 为空或 `mode: default` 可接受,其他网络模式返回 unsupported。
- trigger 支持 `cron`、`interval`、`timeout`、`event`,每个 trigger 必须恰好指定一种类型。
- `scheduler.script` 可以是 inline QJS scalar,也可以是仅含非空 `url` 字段的
mapping。URL 内容会规范化成相同的受管 loader 内联 `script` 快照。空白 inline
Expand Down
93 changes: 93 additions & 0 deletions pkg/agentcompose/adapters/project_networks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package adapters

import (
"context"
"fmt"

cerrdefs "github.com/containerd/errdefs"
containerapi "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
networkapi "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"

"agent-compose/pkg/networks"
)

const (
dockerSandboxProjectLabel = "agent-compose.project_id"
dockerSandboxDriverLabel = "agent-compose.driver"
)

type dockerProjectNetworkClient interface {
NetworkList(context.Context, networkapi.ListOptions) ([]networkapi.Summary, error)
NetworkInspect(context.Context, string, networkapi.InspectOptions) (networkapi.Inspect, error)
ContainerInspect(context.Context, string) (containerapi.InspectResponse, error)
NetworkDisconnect(context.Context, string, string, bool) error
NetworkRemove(context.Context, string) error
Close() error
}

type DockerProjectNetworkCleaner struct {
newClient func() (dockerProjectNetworkClient, error)
}

func NewDockerProjectNetworkCleaner() *DockerProjectNetworkCleaner {
return &DockerProjectNetworkCleaner{newClient: func() (dockerProjectNetworkClient, error) {
return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
}}
}

func (c *DockerProjectNetworkCleaner) CleanupProjectNetworks(ctx context.Context, projectID string) error {
if c == nil || c.newClient == nil {
return fmt.Errorf("docker project network cleaner is not configured")
}
dockerClient, err := c.newClient()
if err != nil {
return fmt.Errorf("connect Docker Engine for project network cleanup: %w", err)
}
defer func() { _ = dockerClient.Close() }()
filter := filters.NewArgs(
filters.Arg("label", networks.ManagedLabel+"=true"),
filters.Arg("label", networks.ResourceLabel+"="+networks.ProjectNetworkResource),
filters.Arg("label", networks.ProjectIDLabel+"="+projectID),
)
summaries, err := dockerClient.NetworkList(ctx, networkapi.ListOptions{Filters: filter})
if err != nil {
return fmt.Errorf("list Docker networks for project %s: %w", projectID, err)
}
for _, summary := range summaries {
inspect, err := dockerClient.NetworkInspect(ctx, summary.ID, networkapi.InspectOptions{})
if err != nil {
if cerrdefs.IsNotFound(err) {
continue
}
return fmt.Errorf("inspect Docker network %s: %w", summary.ID, err)
}
if inspect.Labels[networks.ManagedLabel] != "true" || inspect.Labels[networks.ResourceLabel] != networks.ProjectNetworkResource || inspect.Labels[networks.ProjectIDLabel] != projectID {
return fmt.Errorf("refuse to clean Docker network %s with mismatched ownership", inspect.Name)
Comment thread
monkeyscan[bot] marked this conversation as resolved.
}
for containerID := range inspect.Containers {
containerInfo, err := dockerClient.ContainerInspect(ctx, containerID)
if err != nil {
if cerrdefs.IsNotFound(err) {
continue
}
return fmt.Errorf("inspect endpoint container %s on network %s: %w", containerID, inspect.Name, err)
}
labels := map[string]string(nil)
if containerInfo.Config != nil {
labels = containerInfo.Config.Labels
}
if labels[dockerSandboxProjectLabel] != projectID || labels[dockerSandboxDriverLabel] != "docker" {
return fmt.Errorf("refuse to disconnect unknown endpoint container %s from project network %s", containerID, inspect.Name)
}
if err := dockerClient.NetworkDisconnect(ctx, inspect.ID, containerID, false); err != nil && !cerrdefs.IsNotFound(err) {
return fmt.Errorf("disconnect container %s from project network %s: %w", containerID, inspect.Name, err)
}
}
if err := dockerClient.NetworkRemove(ctx, inspect.ID); err != nil && !cerrdefs.IsNotFound(err) {
return fmt.Errorf("remove project network %s: %w", inspect.Name, err)
}
}
return nil
}
Loading
Loading