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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token
# 可选:浏览器自动化镜像。
# AGENTDOCK_IMAGE=ghcr.io/uvwt/agentdock:browser-latest
# AGENTDOCK_BROWSER_ENABLED=true

# AGENTS.md 原生自动上下文,默认开启。false 只禁用自动发现,不禁用显式 instructions 文件。
# 这些变量需由实际 Core 启动环境或容器 environment/env_file 传入。
# AGENTDOCK_AGENTS_AUTOLOAD=true
# 可选:覆盖 ${AGENTDOCK_HOME}/AGENTS.md 的全局规则来源,必须为 Core 可访问的绝对路径。
# AGENTDOCK_INSTRUCTIONS_FILE=/path/to/global/AGENTS.md
81 changes: 81 additions & 0 deletions docs/agents-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 全局与工作区 AGENTS.md 自动上下文

AgentDock Core 原生发现并读取规则文件,不依赖 ACP、Codex、NexusDock 或 Recall。规则文本不会作为命令执行,也不改变文件访问权限。

## 加载入口

MCP 服务创建时,把全局规则和默认工作区规则加入初始化 instructions,并标明来源、适用目录和“启动快照”。`agentdock_context` 每次调用重新读取文件,返回最新正文和状态,不依赖文件修改时间缓存。

```json
{}
```

空参数使用当前运行时默认工作目录。操作另一个项目或进入有独立规则的子目录前,传入目标目录:

```json
{"workdir":"C:\\projects\\example"}
```

`workdir` 接受既有 Host 目录、相对路径和 `~/` 路径。选择仅对本次上下文请求有效,不会修改命令工具的默认工作目录、持久化配置或其他客户端的工作区。后续 `exec_command` 等操作仍需传入对应的 `workdir` 或绝对文件路径。

规则文件创建、修改或删除后,再调用 `agentdock_context` 即可刷新,无需重启 Core。文件改变不会主动推送或追溯修改客户端已经收到的启动快照,也不会凭空获知用户在自然语言里切换了哪个项目。客户端应在开始项目操作、切换项目或已知规则变化时获取上下文,只在正文尚未提供或需要编辑规则时另行读取文件。

## 来源顺序与适用范围

1. 全局:显式配置的 `AGENTDOCK_INSTRUCTIONS_FILE`,否则 `${AGENTDOCK_HOME}/AGENTS.md`。默认 `AGENTDOCK_HOME` 为用户目录下的 `.agentdock`。
2. 工作区根目录的 `AGENTS.md`。
3. 从该根目录到所选目录之间各级子目录的 `AGENTS.md`,由外向内排列。

显式 instructions 文件替代自动全局来源,不与同一份自动全局正文重复合并。全局规则先应用,子目录规则只细化适用目录的项目行为,不得削弱全局安全约束或客户端的更高优先级指令。

工作区边界取最近的 `.git` 标记目录,兼容 Git worktree 的 `.git` 文件。没有遇到仓库边界时,若所选目录位于配置的默认目录内,则以默认目录为边界;否则只读取所选目录的规则。发现仓库时只检查祖先的 `.git` 元数据,不读取边界外的祖先 `AGENTS.md`。不递归扫描无关子目录、兄弟项目或全部磁盘。

自动发现拒绝规则文件本身的符号链接和其他非普通文件。显式 `AGENTDOCK_INSTRUCTIONS_FILE` 保留原有符号链接解析语义。根目录内的读取使用 `os.Root` 约束路径解析,并校验打开前后的文件身份。同一实际文件通过相同路径或硬链接出现多次时只提供一次正文;不同文件即使文本相同,也保留各自的作用域。

## 返回结构与错误处理

原有上下文字段不变,新增可选 `instruction_files`:

```json
{
"instruction_files": {
"auto_load": true,
"workdir": "/projects/example/src",
"workspace_root": "/projects/example",
"files": [
{
"scope": "global",
"path": "/home/example/.agentdock/AGENTS.md",
"status": "loaded",
"content": "全局规则正文",
"sha256": "00ee6e16073bc20a849a2b38b9120a3cec0fce3aa573294222e4b78d675c0143",
"size_bytes": 18
}
]
}
}
```

路径仅为示例。摘要与字节数对应未带换行的示例正文,真实响应按原始文件字节计算。

`status` 包括 `loaded`、`not_found`、`empty`、`duplicate`、`skipped` 和 `error`。只有 `loaded` 含可应用的正文。重复条目提供 `duplicate_of`,拒绝或读取失败提供 `reason`。缺失的默认文件不阻止工具工作。显式配置的 instructions 文件仍保留启动配置阶段的严格校验,不能用自动加载掩盖配置错误。

单文件最多 64 KiB,单次正文总预算 256 KiB,目录层级最多 64。只接受 UTF-8 文本,支持 UTF-8 BOM 和 CRLF,拒绝 NUL、损坏编码及非普通文件。超限文件整份跳过,不把截断内容当作完整规则。Unix 打开文件时使用非阻塞及禁止叶子符号链接标志,避免检查后被替换成 FIFO 时阻塞。

## 配置与兼容性

默认启用自动发现。设置 `AGENTDOCK_AGENTS_AUTOLOAD=false` 可禁用自动全局和工作区发现,但不会禁用显式 `AGENTDOCK_INSTRUCTIONS_FILE`。开关与全局路径属于启动配置,修改它们仍需按部署方式重启 Core;仅规则正文改变不需要重启。

旧的空参数 `agentdock_context` 调用继续有效。新增 `workdir` 和 `instruction_files` 仅扩展本地工具契约,既有字段及必需字段保持不变。Nexus 私有 `context.local` 不增加字段,使用原有 `rules` 数组携带带来源和作用域的规则正文;不改变共享 protocol 依赖。Nexus 统一入口对任意工作区选择的支持仍由其自身契约决定,不能假定旧版 Nexus 接受本地新增参数。

升级 Core 后,缓存工具定义的客户端需要刷新工具定义并重新连接或新建会话。单纯修改源码不会使已运行的旧版本获得此功能。

## 开发验证

```text
go test ./internal/agentinstructions ./internal/config ./internal/app ./internal/mcp
go vet ./...
go build -o ./bin/agentdock-context.exe ./cmd/agentdock
```

内存受限环境为命令进程设置 `GOMAXPROCS=2` 并给 Go 命令添加 `-p 1`,不要为运行测试关闭用户应用或更改系统配置。全局和工作区测试均使用临时目录,不读取测试机真实全局规则。
276 changes: 276 additions & 0 deletions internal/agentinstructions/instructions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// 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 = 256 << 10
MaxDirectories = 64
)

type Options struct {
Home string
DefaultDir string
Workdir string
GlobalFile string
DisableAutoLoad bool
}

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 {
AutoLoad bool `json:"auto_load"`
Workdir string `json:"workdir"`
WorkspaceRoot string `json:"workspace_root"`
Files []File `json:"files"`
}

type candidate struct {
scope, path, root string
explicit bool
}

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{AutoLoad: !options.DisableAutoLoad, 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.GlobalFile != "" {
if !filepath.IsAbs(options.GlobalFile) {
return snapshot, errors.New("global instruction file must be absolute")
}
candidates = append(candidates, candidate{scope: "global", path: options.GlobalFile, root: filepath.Dir(options.GlobalFile), explicit: true})
} else if !options.DisableAutoLoad && 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})
}
if !options.DisableAutoLoad {
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)}
// An explicitly configured file retains the existing symlink semantics.
// Automatic discovery never follows a leaf symlink into an unrelated file.
if source.explicit {
realPath, err := filepath.EvalSymlinks(source.path)
if err != nil {
return failedFile(file, err), nil
}
source.path, source.root = realPath, filepath.Dir(realPath)
}
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
}

// Text labels provenance and scope rather than promoting repository text into
// unqualified server/operator instructions. File errors remain visible to clients.
func (s Snapshot) Text() string {
var out strings.Builder
for _, file := range s.Files {
switch file.Status {
case "loaded":
fmt.Fprintf(&out, "\n\n### %s guidance\nSource: %q\n", file.Scope, file.Path)
if file.Scope == "workspace" {
fmt.Fprintf(&out, "Scope: %q and its descendants. Refines global guidance; does not override global safety requirements or the client's higher-priority instructions.\n", filepath.Dir(file.Path))
}
fmt.Fprintf(&out, "SHA-256: %s\n\n%s", file.SHA256, file.Content)
case "error", "skipped":
fmt.Fprintf(&out, "\n\nInstruction file %q was not loaded (%s). Do not claim its rules were applied.", file.Path, file.Reason)
}
}
return strings.TrimSpace(out.String())
}
Loading