Skip to content
Merged
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
3 changes: 2 additions & 1 deletion core-skills/agentdock-user-guide/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@
| `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL` | OAuth Access Token 有效期 | Desktop/高级启动配置 |
| `AGENTDOCK_STDIO` | 是否启用 stdio 运行模式 | 直接启动/集成场景 |
| `AGENTDOCK_TRUSTED_PROXY_CIDRS` | 受信任反向代理网段 | 服务器/反代场景 |
| `AGENTDOCK_INSTRUCTIONS_FILE` | 额外 Instructions 文件 | 高级启动配置 |

Coding Agent 的发现、Codex / Claude Adapter 安装、Grok stdio 模式、平台配置和验证流程见 `acp.md`。

项目规则不通过环境变量配置:全局规则固定为 `~/.agentdock/AGENTS.md`,工作区规则与 `.agents/skills` 索引通过 `workspace_context` 按请求读取。

## 重要边界

- Windows Desktop 不应把认证秘密直接写入 `control-panel-settings.json`。Bearer Token、OAuth 密码、OAuth 签名密钥和 Tunnel Token 使用平台受保护存储。
Expand Down
42 changes: 42 additions & 0 deletions docs/agents-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Workspace 上下文

AgentDock 把设备级能力总览与项目级规则分开处理:`agentdock_context` 只返回 Runtime、Skill、动态 MCP、Workflow、Recall、ACP 和稳定操作规则;项目规则与工作区 Skill 由独立的 `workspace_context` 按请求读取。

## 规则文件

AgentDock 只识别两类 `AGENTS.md`:

1. 全局规则固定为 `~/.agentdock/AGENTS.md`;不提供自定义全局规则路径。
2. 工作区规则从 workspace root 的 `AGENTS.md` 开始,到本次 `workdir` 之间逐级继承子目录 `AGENTS.md`。

workspace root 优先使用离 `workdir` 最近的 Git / worktree 边界;在 AgentDock 默认工作目录内部且没有更近 Git 边界时,以默认工作目录为边界;两者都不存在时只检查当前 `workdir`。

`workspace_context` 每次调用都重新读取文件,不使用 mtime 缓存,也不会修改进程 cwd、AgentDock 默认工作目录或后续命令的默认目录。开始操作具体项目、切换工作区或规则可能变化时应重新调用。

## workspace_context

直接连接 AgentDock 时输入只有一个可选字段:

```json
{"workdir": "/absolute/or/host-resolvable/workspace"}
```

省略 `workdir` 使用 AgentDock 当前默认工作目录。返回结构包含:

- `workdir`:本次实际解析的工作目录;
- `workspace_root`:规则继承和 workspace Skill 扫描的根目录;
- `instructions`:按全局 → workspace root → 子目录顺序排列的规则文件状态与完整正文;
- `workspace_skills`:`<workspace>/.agents/skills/*/SKILL.md` 的 name / description / file 索引,不返回 Skill 正文;
- `warnings`:无法生成某个 best-effort 索引时的安全提示。

`instructions` 中每项包含 `scope`、`path`、`status`,并按状态提供 `content`、`sha256`、`size_bytes`、`reason` 或 `duplicate_of`。状态可能是 `loaded`、`not_found`、`empty`、`duplicate`、`skipped`、`error`。只有 `loaded` 的正文是完整有效规则;AgentDock 不会截断正文后伪装成成功加载。

## 安全与预算

每个 `AGENTS.md` 最多 64 KiB,单次 `workspace_context` 的规则正文总预算为 128 KiB。超过总预算的后续文件保留 metadata 并标记 `skipped`。读取要求 UTF-8 文本且拒绝 NUL、leaf symlink、FIFO 和其他非普通文件;使用 `os.Root`、文件身份校验和物理文件去重限制竞态与路径逃逸。

Workspace Skill 固定扫描 `<workspace>/.agents/skills/<skill-name>/SKILL.md`,只建立 metadata 索引。需要执行 Skill 时再用 `read_file` 读取返回的 `file`。选择同名能力时优先级为 workspace Skill → AgentDock Skill → `~/.agents/skills` common Skill。

## MCP 初始化

MCP 初始化 instructions 只包含稳定的 AgentDock 使用说明,不注入任何全局或工作区 `AGENTS.md` 正文。这样同一 Core 切换项目时不会在初始化上下文里残留旧工作区规则。
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/rogpeppe/go-internal v1.15.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/uvwt/agentdock-protocol v0.8.1
github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb
golang.org/x/sys v0.45.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2K
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/uvwt/agentdock-protocol v0.8.1 h1:DweChXqBJk8EkWZYenGasyPDcDXL0w2lvXM9lyPYlL0=
github.com/uvwt/agentdock-protocol v0.8.1/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk=
github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb h1:M55sK4PytzsvYRTgzOZyMkHMCSeo3WH0VnJk3mneGBo=
github.com/uvwt/agentdock-protocol v0.8.2-0.20260922024821-59aad94f04bb/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
Expand Down
238 changes: 238 additions & 0 deletions internal/agentinstructions/instructions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Package agentinstructions discovers bounded, workspace-scoped AGENTS.md guidance.
// It has no mutable workspace state and never executes instructions or file contents.
package agentinstructions

import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
)

const (
Filename = "AGENTS.md"
MaxFileBytes = 64 << 10
MaxTotalBytes = 128 << 10
MaxDirectories = 64
)

type Options struct {
Home string
DefaultDir string
Workdir string
}

type File struct {
Scope string `json:"scope"`
Path string `json:"path"`
Status string `json:"status"`
Content string `json:"content,omitempty"`
SHA256 string `json:"sha256,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Reason string `json:"reason,omitempty"`
DuplicateOf string `json:"duplicate_of,omitempty"`
}

type Snapshot struct {
Workdir string `json:"workdir"`
WorkspaceRoot string `json:"workspace_root"`
Files []File `json:"files"`
}

type candidate struct {
scope, path, root string
}

type loadedFile struct {
info os.FileInfo
path string
}

// Load reads fresh content on each request. Missing optional files are normal;
// unreadable or invalid files are reported without including partial instructions.
func Load(ctx context.Context, options Options) (Snapshot, error) {
snapshot := Snapshot{Workdir: options.Workdir, WorkspaceRoot: options.Workdir, Files: []File{}}
if err := ctx.Err(); err != nil {
return snapshot, err
}
if !filepath.IsAbs(options.Workdir) {
return snapshot, errors.New("instruction workdir must be an absolute directory")
}
info, err := os.Stat(options.Workdir)
if err != nil || !info.IsDir() {
return snapshot, errors.New("instruction workdir must be an existing directory")
}
candidates := []candidate{}
if options.Home != "" {
if !filepath.IsAbs(options.Home) {
return snapshot, errors.New("instruction home must be absolute")
}
candidates = append(candidates, candidate{scope: "global", path: filepath.Join(options.Home, Filename), root: options.Home})
}
dirs, err := workspaceDirectories(ctx, options.Workdir, options.DefaultDir)
if err != nil {
return snapshot, err
}
snapshot.WorkspaceRoot = dirs[0]
for _, dir := range dirs {
candidates = append(candidates, candidate{scope: "workspace", path: filepath.Join(dir, Filename), root: dirs[0]})
}
seen := []loadedFile{}
remaining := int64(MaxTotalBytes)
for _, source := range candidates {
if err := ctx.Err(); err != nil {
return snapshot, err
}
file, info := readCandidate(source)
if file.Status == "loaded" {
for _, prior := range seen {
if os.SameFile(prior.info, info) {
file.Status, file.Content, file.DuplicateOf = "duplicate", "", prior.path
break
}
}
if file.Status == "loaded" {
if file.SizeBytes > remaining {
file.Status, file.Content, file.Reason = "skipped", "", "total_size_limit"
} else {
remaining -= file.SizeBytes
seen = append(seen, loadedFile{info: info, path: file.Path})
}
}
}
snapshot.Files = append(snapshot.Files, file)
}
return snapshot, ctx.Err()
}

// Only repository ancestors (or ancestors inside the configured default directory)
// are eligible. We never read parent AGENTS.md files outside this boundary.
func workspaceDirectories(ctx context.Context, workdir, defaultDir string) ([]string, error) {
boundary := ""
if filepath.IsAbs(defaultDir) && within(defaultDir, workdir) {
boundary = filepath.Clean(defaultDir)
}
root := workdir
found := false
for dir, count := workdir, 0; ; dir, count = filepath.Dir(dir), count+1 {
if err := ctx.Err(); err != nil {
return nil, err
}
if count >= MaxDirectories {
return nil, errors.New("workspace instruction discovery exceeds directory limit")
}
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
root, found = dir, true
break
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("cannot inspect workspace boundary: %w", err)
}
if boundary != "" {
// filepath.Rel applies the host's path equality rules, including
// case-insensitive drive and directory names on Windows.
if rel, err := filepath.Rel(boundary, dir); err == nil && rel == "." {
root, found = dir, true
break
}
}
if filepath.Dir(dir) == dir {
break
}
}
if !found {
return []string{workdir}, nil
}
dirs := []string{}
for dir := workdir; ; dir = filepath.Dir(dir) {
if len(dirs) >= MaxDirectories {
return nil, errors.New("workspace instruction inheritance exceeds directory limit")
}
dirs = append(dirs, dir)
if dir == root {
break
}
}
slices.Reverse(dirs)
return dirs, nil
}

func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}

func readCandidate(source candidate) (File, os.FileInfo) {
file := File{Scope: source.scope, Path: filepath.Clean(source.path)}
// AGENTS.md discovery never follows a leaf symlink into another file.
root, err := os.OpenRoot(source.root)
if err != nil {
return failedFile(file, err), nil
}
defer root.Close()
rel, err := filepath.Rel(source.root, source.path)
if err != nil || !within(source.root, source.path) {
file.Status, file.Reason = "skipped", "outside_scope"
return file, nil
}
before, err := root.Lstat(rel)
if err != nil {
return failedFile(file, err), nil
}
if !before.Mode().IsRegular() {
file.Status, file.Reason = "skipped", "not_regular_file"
return file, nil
}
if before.Size() > MaxFileBytes {
file.Status, file.Reason, file.SizeBytes = "skipped", "file_size_limit", before.Size()
return file, nil
}
opened, err := root.OpenFile(rel, instructionOpenFlags(), 0)
if err != nil {
return failedFile(file, err), nil
}
defer opened.Close()
after, err := opened.Stat()
if err != nil {
return failedFile(file, err), nil
}
if !after.Mode().IsRegular() || !os.SameFile(before, after) {
file.Status, file.Reason = "skipped", "file_changed_during_read"
return file, nil
}
data, err := io.ReadAll(io.LimitReader(opened, MaxFileBytes+1))
if err != nil {
return failedFile(file, err), nil
}
file.SizeBytes = int64(len(data))
if len(data) > MaxFileBytes {
file.Status, file.Reason = "skipped", "file_size_limit"
return file, nil
}
if !utf8.Valid(data) || strings.ContainsRune(string(data), 0) {
file.Status, file.Reason = "skipped", "invalid_utf8_text"
return file, nil
}
file.Content = strings.TrimSpace(strings.TrimPrefix(string(data), "\ufeff"))
if file.Content == "" {
file.Status = "empty"
return file, nil
}
file.Status, file.SHA256 = "loaded", fmt.Sprintf("%x", sha256.Sum256(data))
return file, after
}

func failedFile(file File, err error) File {
file.Status, file.Reason = "error", "read_failed"
if errors.Is(err, os.ErrNotExist) {
file.Status, file.Reason = "not_found", ""
} else if errors.Is(err, os.ErrPermission) {
file.Reason = "permission_denied"
}
return file
}
Loading
Loading