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
1 change: 0 additions & 1 deletion edge-server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ go 1.26.5

require (
github.com/coder/acp-go-sdk v0.13.5
github.com/fsnotify/fsnotify v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
Expand Down
2 changes: 0 additions & 2 deletions edge-server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
Expand Down
48 changes: 5 additions & 43 deletions edge-server/internal/adapters/acp/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,15 @@ import (
"github.com/agenthub/edge-server/internal/store"
)

// acpAdapterID is the registry identifier for the experimental ACP adapter.
const acpAdapterID = "acp"

// acpExperimentalVersion is the Metadata.Version surfaced by the generic
// experimental AcpAdapter (no concrete agent pin).
const acpExperimentalVersion = "acp-experimental"

// AcpAdapterConfig fully describes a concrete ACP agent configuration. It is
// the single entry point for registering an ACP-backed agent (codex-acp,
// claude-acp, opencode-acp, …): the generic NewAcpAdapter/NewAcpAdapterWithID
// constructors are retained for the experimental "acp" entry and tests, but
// concrete agent configs should go through NewAcpAdapterConfig so the env
// passthrough, version label, and preflight messaging are sourced from one
// place instead of being re-implemented per wrapper.
// claude-acp, opencode-acp, …), so env passthrough, version label, and
// preflight messaging are sourced from one place instead of being
// re-implemented per wrapper.
type AcpAdapterConfig struct {
// ID is the registry identifier (e.g. "codex-acp", "claude-acp").
ID string
Expand Down Expand Up @@ -96,8 +91,8 @@ type AcpAdapterConfig struct {
// Reference: #1404, ACP spike analysis, ACP Go migration (option C').
type AcpAdapter struct {
// id is the registry identifier for this adapter instance. The generic
// NewAcpAdapter uses "acp"; concrete agent configs (e.g. codex-acp) use
// their own IDs so multiple ACP agents can coexist in the registry.
// "acp" entry and concrete agent configs (e.g. codex-acp) each use their
// own IDs so multiple ACP agents can coexist in the registry.
id string

// agentBinary is the path or command name of the ACP agent executable.
Expand Down Expand Up @@ -130,24 +125,6 @@ type AcpAdapter struct {
permissionBroker *adapters.PermissionDecisionBroker
}

// NewAcpAdapter creates an experimental ACP adapter for the given agent binary.
//
// agentBinary must be an executable on PATH (or an absolute path to an
// ACP-compatible executable). agentArgs are appended after the ACP protocol
// flag. displayName is shown in agent listings. The adapter registers under
// the generic "acp" ID; use NewAcpAdapterWithID for concrete agent configs.
func NewAcpAdapter(agentBinary string, agentArgs []string, displayName string) *AcpAdapter {
return newAcpAdapter(acpAdapterID, agentBinary, agentArgs, displayName)
}

// NewAcpAdapterWithID is like NewAcpAdapter but with an explicit registry ID,
// so a concrete ACP agent configuration (e.g. "codex-acp" backed by
// `npx -y @agentclientprotocol/codex-acp`) can be registered alongside the
// generic "acp" adapter.
func NewAcpAdapterWithID(id, agentBinary string, agentArgs []string, displayName string) *AcpAdapter {
return newAcpAdapter(id, agentBinary, agentArgs, displayName)
}

// NewAcpAdapterConfig builds an AcpAdapter from a fully-specified concrete
// agent configuration. This is the preferred entry point for codex-acp,
// claude-acp, opencode-acp, …: env passthrough, version label, and preflight
Expand Down Expand Up @@ -176,21 +153,6 @@ func NewAcpAdapterConfig(cfg AcpAdapterConfig) *AcpAdapter {
}
}

func newAcpAdapter(id, agentBinary string, agentArgs []string, displayName string) *AcpAdapter {
return &AcpAdapter{
id: id,
agentBinary: agentBinary,
agentArgs: agentArgs,
permissionBroker: nil,
metadata: AdapterMetadata{
ID: id,
Name: displayName,
Version: acpExperimentalVersion,
Description: "ACP agent (experimental — JSON-RPC 2.0 over stdio)",
},
}
}

// SetPermissionBroker installs the shared adapters.PermissionDecisionBroker that
// session/request_permission requests are bridged to (mirrors
// ClaudeCodeAdapter.SetPermissionBroker; the API layer's
Expand Down
2 changes: 1 addition & 1 deletion edge-server/internal/adapters/acp/acp_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1214,7 +1214,7 @@ func TestAcpBinaryAvailable(t *testing.T) {
// non-empty metadata(#1760 acp 增量:随 acp 家族从根包
// TestAdapterMetadataIsNotEmpty 迁入,逻辑未改)。
func TestAcpAdapterMetadataIsNotEmpty(t *testing.T) {
metadata := NewAcpAdapter("acp", nil, "ACP experimental").Metadata()
metadata := NewAcpAdapterConfig(AcpAdapterConfig{ID: "acp", Binary: "acp", DisplayName: "ACP experimental"}).Metadata()
if metadata.ID == "" {
t.Fatal("ACP adapter ID is empty")
}
Expand Down
4 changes: 2 additions & 2 deletions edge-server/internal/adapters/claude/claude_acp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func TestClaudeACPAdapterVersionPin(t *testing.T) {
// fails before spawn.
func TestClaudeACPAdapterPreflightFailsFast(t *testing.T) {
a := &ACPAdapter{
AcpAdapter: acp.NewAcpAdapterWithID(claudeACPAdapterID, "", nil, "Claude Code (ACP)"),
AcpAdapter: acp.NewAcpAdapterConfig(acp.AcpAdapterConfig{ID: claudeACPAdapterID, DisplayName: "Claude Code (ACP)"}),
}
if a.Available() {
t.Fatal("empty binary must not be available")
Expand Down Expand Up @@ -331,7 +331,7 @@ func TestClaudeACPAdapterRegistryRegistration(t *testing.T) {
if _, ok := reg.Get("acp"); ok {
t.Error("generic acp adapter unexpectedly registered")
}
if err := reg.Register(acp.NewAcpAdapter("fake-agent", nil, "Fake")); err != nil {
if err := reg.Register(acp.NewAcpAdapterConfig(acp.AcpAdapterConfig{ID: "acp", Binary: "fake-agent", DisplayName: "Fake"})); err != nil {
t.Errorf("generic acp registration should coexist with claude-acp: %v", err)
}
if !containsString(reg.ListIDs(), "claude-acp") {
Expand Down
2 changes: 1 addition & 1 deletion edge-server/internal/adapters/codex/codex_acp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ func TestCodexACPadapterRegistryRegistration(t *testing.T) {
if _, ok := reg.Get("acp"); ok {
t.Error("generic acp adapter unexpectedly registered")
}
if err := reg.Register(acp.NewAcpAdapter("fake-agent", nil, "Fake")); err != nil {
if err := reg.Register(acp.NewAcpAdapterConfig(acp.AcpAdapterConfig{ID: "acp", Binary: "fake-agent", DisplayName: "Fake"})); err != nil {
t.Errorf("generic acp registration should coexist with codex-acp: %v", err)
}
if !containsString(reg.ListIDs(), "codex-acp") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ func TestOpenCodeACPAdapterDefaultBinaryPath(t *testing.T) {

func TestOpenCodeACPAdapterPreflightFailsFast(t *testing.T) {
a := &ACPAdapter{
AcpAdapter: acp.NewAcpAdapterWithID(opencodeACPAdapterID, "", nil, "OpenCode (ACP)"),
AcpAdapter: acp.NewAcpAdapterConfig(acp.AcpAdapterConfig{ID: opencodeACPAdapterID, DisplayName: "OpenCode (ACP)"}),
}
if a.Available() {
t.Fatal("empty binary must not be available")
Expand Down
65 changes: 0 additions & 65 deletions edge-server/internal/skills/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,56 +59,6 @@ func ParseFrontmatter(path string) (*Skill, error) {
}, nil
}

// ParseBody reads only the markdown body (content after the closing "---"
// delimiter) from a SKILL.md file.
func ParseBody(path string) (string, error) {
// #nosec G304 -- skill paths come from the configured skills dir (operator)
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("skills: read %s: %w", path, err)
}

content := string(data)
// Find the second "---" delimiter that closes the frontmatter.
// The frontmatter starts at the first "---" on line 1.
idx := strings.Index(content, "---")
if idx != 0 {
// Frontmatter may not start at column 0 (e.g. BOM). Find the first line.
lines := strings.SplitN(content, "\n", 2)
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
// Continue with the rest.
} else {
return "", fmt.Errorf("skills: missing frontmatter opening in %s", path)
}
}

// Find the closing "---": search from after the first line.
rest := content
if idx == 0 {
nl := strings.Index(rest, "\n")
if nl < 0 {
return "", fmt.Errorf("skills: malformed frontmatter in %s", path)
}
rest = rest[nl+1:]
}

endIdx := strings.Index(rest, "\n---")
if endIdx < 0 {
// Maybe there's no body at all.
return "", nil
}

bodyStart := endIdx + 4 // skip "\n---"
if bodyStart >= len(rest) {
return "", nil
}

body := rest[bodyStart:]
// Strip leading newline if present.
body = strings.TrimPrefix(body, "\n")
return body, nil
}

// parseFrontmatterLine parses a single "key: value" line from YAML frontmatter.
// Handles quoted strings and bare values.
func parseFrontmatterLine(line string) (key, value string) {
Expand Down Expand Up @@ -167,18 +117,3 @@ func parseTriggersValue(value string) []string {
}
return []string{strings.ToLower(value)}
}

// ParseFull reads and parses the entire SKILL.md file at path into a Skill
// with both frontmatter and body populated.
func ParseFull(path string) (*Skill, error) {
s, err := ParseFrontmatter(path)
if err != nil {
return nil, err
}
body, err := ParseBody(path)
if err != nil {
return nil, err
}
s.Body = body
return s, nil
}
110 changes: 36 additions & 74 deletions edge-server/internal/skills/skill.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package skills provides SKILL.md discovery, parsing, progressive loading,
// trigger-word matching, and hot reload via filesystem watch (fsnotify).
// Package skills provides SKILL.md discovery, parsing, and system-prompt
// injection for Edge runs.
//
// SKILL.md files follow the Codex CLI standard with YAML frontmatter:
//
Expand All @@ -12,81 +12,39 @@
// ---
// # Markdown body
//
// The SkillRegistry supports lazy loading: Discover loads only name+description;
// LoadBody reads the full markdown body on demand, typically triggered by
// explicit invocation ($skill-name) or trigger-word matching.
// Discover loads name + description + triggers from the frontmatter only
// (lightweight); SystemPromptContext injects the "Available skills:" block
// into the agent system prompt during run creation (handlers_runs.go).
//
// Hot reload watches the skills directory tree for SKILL.md changes (create,
// modify, rename, delete) and updates the registry in real time without a full
// rescan. The watch is recursive on top-level subdirectories and uses a 500ms
// debounce to coalesce rapid successive events.
// Historical trigger-word matching and fsnotify hot reload were removed:
// they had zero production callers, and restoring them from git history is
// straightforward if a future feature needs them (#2154).
package skills

import (
"regexp"
"strings"
"sync"
"time"

"github.com/fsnotify/fsnotify"
)

// Skill represents a parsed SKILL.md file.
// All fields except Body are populated by Discover/ParseFrontmatter.
// Body is loaded lazily via LoadBody to keep startup fast with progressive loading.
// Skill represents a parsed SKILL.md file frontmatter.
type Skill struct {
Name string // from YAML frontmatter "name"
Description string // from YAML frontmatter "description"
Triggers []string // from YAML frontmatter "triggers" (comma-separated or list); case-insensitive matching
Body string // full markdown body (after frontmatter); empty until LoadBody is called
Triggers []string // from YAML frontmatter "triggers" (comma-separated or list); case-insensitive
Path string // absolute file path to the SKILL.md

// compiledTriggers caches pre-compiled regexp for word-boundary matching.
// Populated by compileTriggers() during Discover or reloadSkill.
// Each regexp is (?i)\b + regexp.QuoteMeta(trigger) + \b, safe from ReDoS
// because regexp.QuoteMeta escapes all metacharacters.
compiledTriggers []*regexp.Regexp
}

// SkillSummary is the lightweight view of a skill (name + description only).
// It is produced by Discover without reading the full markdown body,
// enabling fast startup with progressive loading.
type SkillSummary struct {
Name string
Description string
Path string
}

// SkillRegistry holds discovered skills and supports name lookup, body loading,
// trigger-word matching, and hot reload via filesystem watch.
//
// Thread-safety: all exported methods acquire the appropriate lock (RWMutex).
// Internal methods (matchTriggerWordBoundaryLocked) require the caller to hold
// at least a read lock.
// SkillRegistry holds discovered skills and supports name lookup and
// system-prompt injection. Thread-safe: all methods take the registry lock.
type SkillRegistry struct {
mu sync.RWMutex
skills map[string]*Skill // name -> skill
dirs []string // search directories passed to NewSkillRegistry
loaded bool // true after Discover ran at least once

// UseWordBoundary toggles word-boundary matching in MatchTrigger.
// When false (default), triggers are matched via case-insensitive substring.
// When true, triggers are matched as whole words using pre-compiled regexp (\b).
UseWordBoundary bool

// Hot reload fields. Managed by StartWatch/StopWatch; accessed under
// debounceMu for coordination and r.mu for registry mutations.
watcher *fsnotify.Watcher
stopCh chan struct{}
pendingPaths map[string]struct{} // paths queued for reload (accessed under debounceMu)
debounceTimer *time.Timer
debounceMu sync.Mutex
watchStarted bool // true after StartWatch succeeds
}

// NewSkillRegistry creates a registry that scans the given directories
// for SKILL.md files. Directories are resolved to absolute paths during
// Discover. Call Discover() after construction to populate skills, then
// optionally StartWatch() for hot reload.
// Discover. Call Discover() after construction to populate skills.
func NewSkillRegistry(dirs []string) *SkillRegistry {
return &SkillRegistry{
skills: make(map[string]*Skill),
Expand All @@ -95,10 +53,8 @@ func NewSkillRegistry(dirs []string) *SkillRegistry {
}

// Discover scans all configured directories and populates the registry
// with Skill objects containing name + description (lightweight).
// It does NOT read the full body — use LoadBody for that.
// with Skill objects containing frontmatter fields (lightweight, no body).
// Existing entries with the same name are overwritten (last wins).
// Word-boundary regexp are compiled eagerly for each skill during this call.
func (r *SkillRegistry) Discover() error {
r.mu.Lock()
defer r.mu.Unlock()
Expand All @@ -113,34 +69,40 @@ func (r *SkillRegistry) Discover() error {
if err != nil {
continue // skip unparseable files
}
skill.compileTriggers()
r.skills[skill.Name] = skill
}
}
r.loaded = true
return nil
}

// Get returns a skill by name (may or may not have body loaded).
func (r *SkillRegistry) Get(name string) (*Skill, bool) {
// Count returns the number of discovered skills. Used for the startup log.
func (r *SkillRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.skills[name]
return s, ok
return len(r.skills)
}

// List returns all discovered skill summaries.
func (r *SkillRegistry) List() []SkillSummary {
// SystemPromptContext builds the "Available skills:" block for injection
// into the agent system prompt. Only includes name + description
// (lightweight). Returns an empty string when no skills are loaded.
func (r *SkillRegistry) SystemPromptContext() string {
r.mu.RLock()
defer r.mu.RUnlock()

out := make([]SkillSummary, 0, len(r.skills))
if len(r.skills) == 0 {
return ""
}

var b strings.Builder
b.WriteString("Available skills:\n")
for _, s := range r.skills {
out = append(out, SkillSummary{
Name: s.Name,
Description: s.Description,
Path: s.Path,
})
b.WriteString("- ")
b.WriteString(s.Name)
if s.Description != "" {
b.WriteString(": ")
b.WriteString(s.Description)
}
b.WriteString("\n")
}
return out
return b.String()
}
Loading
Loading