Add roles 9P endpoint to server and support multiple tool directories
- Wire up roles filesystem in server.go - Add /agent/roles directory with focus areas and help - Support ANVILLM_TOOLS_DIR env var for multiple tool directories - Update pcloudcc-testing skill documentation
This commit is contained in:
parent
65e5081134
commit
485016127d
|
|
@ -85,6 +85,7 @@ const (
|
|||
qidBeadsReady // beads/ready
|
||||
qidTools // tools directory
|
||||
qidSkills // skills directory
|
||||
qidRoles // roles directory
|
||||
qidSessionBase = 1000
|
||||
qidPeersBase = 0x10000000 // peers/{id}/file
|
||||
qidInboxBase = 0x20000000 // session/{id}/inbox
|
||||
|
|
@ -94,6 +95,7 @@ const (
|
|||
qidBeadsBase = 0x60000000 // beads/{id}/file
|
||||
qidToolsBase = 0x70000000 // tools/{capability}/{tool}
|
||||
qidSkillsBase = 0x80000000 // skills/{intent}/{skill}
|
||||
qidRolesBase = 0x90000000 // roles/{focus-area}/{role}
|
||||
)
|
||||
|
||||
// File indices within a session directory
|
||||
|
|
@ -127,6 +129,7 @@ type Server struct {
|
|||
beads *BeadsFS
|
||||
tools *ToolsFS
|
||||
skills *SkillsFS
|
||||
roles *RolesFS
|
||||
OnAliasChange func(backend.Session) // Called when session alias changes
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
|
@ -183,6 +186,7 @@ func NewServer(mgr *session.Manager, beadsFS *BeadsFS) (*Server, error) {
|
|||
beads: beadsFS,
|
||||
tools: toolsFS,
|
||||
skills: NewSkillsFS(),
|
||||
roles: NewRolesFS(),
|
||||
}
|
||||
go s.acceptLoop()
|
||||
return s, nil
|
||||
|
|
@ -413,6 +417,9 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
case "skills":
|
||||
qid = plan9.Qid{Type: QTDir, Path: qidSkills}
|
||||
newPath = "/skills"
|
||||
case "roles":
|
||||
qid = plan9.Qid{Type: QTDir, Path: qidRoles}
|
||||
newPath = "/roles"
|
||||
default:
|
||||
// Check if it's a session ID
|
||||
if sess := s.mgr.Get(name); sess != nil {
|
||||
|
|
@ -517,6 +524,26 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
// Inside skills/<intent>/<skill> - files
|
||||
qid = plan9.Qid{Type: QTFile, Path: qidSkillsBase + hashID(path+name)}
|
||||
newPath = path + "/" + name
|
||||
} else if path == "/roles" {
|
||||
// Inside roles directory - list focus areas + help
|
||||
if s.roles != nil {
|
||||
focusAreas, _ := s.roles.listFocusAreas()
|
||||
qid = plan9.Qid{Type: QTFile, Path: qidRolesBase}
|
||||
if name == "help" {
|
||||
newPath = "/roles/help"
|
||||
} else if slices.Contains(focusAreas, name) {
|
||||
qid = plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(name)}
|
||||
newPath = "/roles/" + name
|
||||
} else {
|
||||
return errFcall(fc, "not found")
|
||||
}
|
||||
} else {
|
||||
return errFcall(fc, "not found")
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 {
|
||||
// Inside roles/<focus-area> - role files
|
||||
qid = plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+name)}
|
||||
newPath = path + "/" + name
|
||||
} else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 {
|
||||
// Inside a mount directory
|
||||
mountName := strings.TrimPrefix(path, "/beads/")
|
||||
|
|
@ -1043,6 +1070,10 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|||
Qid: plan9.Qid{Type: QTDir, Path: qidSkills},
|
||||
Mode: plan9.DMDIR | 0555, Name: "skills", Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTDir, Path: qidRoles},
|
||||
Mode: plan9.DMDIR | 0555, Name: "roles", Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
for _, id := range s.mgr.List() {
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTDir, Path: qidSessionBase + hashID(id)},
|
||||
|
|
@ -1116,6 +1147,34 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
|||
})
|
||||
}
|
||||
}
|
||||
} else if path == "/roles" {
|
||||
if s.roles != nil {
|
||||
// Add help file
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase},
|
||||
Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
// Add focus area directories
|
||||
focusAreas, _ := s.roles.listFocusAreas()
|
||||
for _, fa := range focusAreas {
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(fa)},
|
||||
Mode: plan9.DMDIR | 0555, Name: fa, Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 {
|
||||
// Inside roles/<focus-area> - list role files
|
||||
focusArea := strings.TrimPrefix(path, "/roles/")
|
||||
if s.roles != nil {
|
||||
roles, _ := s.roles.listRolesInFocusArea(focusArea)
|
||||
for _, role := range roles {
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+role.Name)},
|
||||
Mode: 0444, Name: role.Name + ".md", Uid: "q", Gid: "q", Muid: "q",
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if path == "/beads" {
|
||||
// Beads directory - only ctl, mtab, ready (aggregate), and mounted projects
|
||||
dirs = append(dirs, plan9.Dir{
|
||||
|
|
@ -1400,6 +1459,18 @@ func (s *Server) readFile(path string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// Handle roles paths
|
||||
if strings.HasPrefix(path, "/roles/") {
|
||||
if s.roles != nil {
|
||||
data, err := s.roles.Read("agent/" + strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle beads paths
|
||||
if beadsPath, ok := strings.CutPrefix(path, "/beads/"); ok {
|
||||
if s.beads != nil {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import (
|
|||
// ToolsFS provides 9P filesystem access to agent tools organized by capability level.
|
||||
// Tools are shell scripts with YAML front-matter defining their interface.
|
||||
type ToolsFS struct {
|
||||
tools []Tool
|
||||
toolsDir string
|
||||
tools []Tool
|
||||
toolsDirs []string
|
||||
}
|
||||
|
||||
// Tool represents an agent tool with its MCP-compatible schema.
|
||||
|
|
@ -55,10 +55,28 @@ type ToolMeta struct {
|
|||
}
|
||||
|
||||
func NewToolsFS(tools []Tool) *ToolsFS {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
var dirs []string
|
||||
|
||||
// Check ANVILLM_TOOLS_DIR first (colon-separated)
|
||||
if envDirs := os.Getenv("ANVILLM_TOOLS_DIR"); envDirs != "" {
|
||||
dirs = strings.Split(envDirs, ":")
|
||||
} else {
|
||||
// Default: Claude, Kiro, AnviLLM config directories
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
var defaults []string
|
||||
if claudeDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeDir != "" {
|
||||
defaults = append(defaults, filepath.Join(claudeDir, "mcptools"))
|
||||
}
|
||||
defaults = append(defaults,
|
||||
filepath.Join(homeDir, ".kiro/mcptools"),
|
||||
filepath.Join(homeDir, ".config/anvillm/mcptools"),
|
||||
)
|
||||
dirs = defaults
|
||||
}
|
||||
|
||||
return &ToolsFS{
|
||||
tools: tools,
|
||||
toolsDir: filepath.Join(homeDir, ".config/anvillm/mcptools"),
|
||||
tools: tools,
|
||||
toolsDirs: dirs,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,23 +116,31 @@ func parseFrontMatter(path string) (*ToolMeta, error) {
|
|||
return meta, nil
|
||||
}
|
||||
|
||||
// listAllTools scans toolsDir and returns metadata for all .sh files
|
||||
// listAllTools scans all tools directories and returns metadata for all .sh files
|
||||
func (t *ToolsFS) listAllTools() ([]*ToolMeta, error) {
|
||||
entries, err := os.ReadDir(t.toolsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var tools []*ToolMeta
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
|
||||
continue
|
||||
}
|
||||
meta, err := parseFrontMatter(filepath.Join(t.toolsDir, entry.Name()))
|
||||
|
||||
for _, dir := range t.toolsDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, meta)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
|
||||
continue
|
||||
}
|
||||
if seen[entry.Name()] {
|
||||
continue
|
||||
}
|
||||
meta, err := parseFrontMatter(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen[entry.Name()] = true
|
||||
tools = append(tools, meta)
|
||||
}
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
|
@ -266,9 +292,16 @@ func (t *ToolsFS) Read(path string) ([]byte, error) {
|
|||
|
||||
for _, tool := range tools {
|
||||
if tool.Name == filename {
|
||||
// Verify path stays within toolsDir
|
||||
relPath, err := filepath.Rel(t.toolsDir, tool.Path)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
// Verify path stays within one of the tools directories
|
||||
valid := false
|
||||
for _, dir := range t.toolsDirs {
|
||||
relPath, err := filepath.Rel(dir, tool.Path)
|
||||
if err == nil && !strings.HasPrefix(relPath, "..") {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("invalid tool path")
|
||||
}
|
||||
return os.ReadFile(tool.Path)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
# capabilities: roles
|
||||
# description: List all available roles (JSON array)
|
||||
set -euo pipefail
|
||||
|
||||
# Verify running under landrun (test filesystem restriction)
|
||||
if cat /etc/shadow >/dev/null 2>&1; then
|
||||
echo "Error: This script must be run via execute_code tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
9p ls agent/roles 2>/dev/null | grep -v '^help$' | while read focus_area; do
|
||||
9p ls "agent/roles/$focus_area" 2>/dev/null | while read role_file; do
|
||||
role_name="${role_file%.md}"
|
||||
desc=$(9p read "agent/roles/$focus_area/$role_file" 2>/dev/null | awk '/^---$/,/^---$/ {if (/^description:/) {sub(/^description: */, ""); print; exit}}')
|
||||
printf '%s\t%s\t%s\n' "$focus_area" "$role_name" "$desc"
|
||||
done
|
||||
done | jq -Rs '
|
||||
split("\n") | map(select(length > 0)) | map(
|
||||
split("\t") | {focus_area: .[0], name: .[1], description: .[2]}
|
||||
)
|
||||
'
|
||||
|
|
@ -45,7 +45,7 @@ Load pcloudcc-testing skill. Test the affected code path with unit tests, fault
|
|||
|
||||
**Kill testing agent**: `bash <(9p read agent/tools/agents/kill_agent.sh) <testing-agent-id>`
|
||||
|
||||
**Note**: Use `execute_bash`, not `execute_code`
|
||||
**Note**: `spawn_agent.sh` is an exception — use the Bash tool (`execute_bash`), NOT `execute_code`. All other commands in this section use `execute_code` as normal.
|
||||
|
||||
### Testing Agent Workflow
|
||||
|
||||
|
|
|
|||
Reference in New Issue