skills support

This commit is contained in:
Luxferre
2026-09-10 10:38:36 +03:00
parent 5b5853a2dd
commit b3f2864f6a
3 changed files with 220 additions and 2 deletions
+18 -1
View File
@@ -70,6 +70,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
- `/cfg <param> [val]` — inspect or update a configuration parameter live (writes to `.bantam.cfg`)
- `/model [val]` — alias for `/cfg model` (inspect or set the model)
- `/endpoint [val]` — alias for `/cfg endpoint` (inspect or set the API endpoint)
- `/skill` — with no argument, list every skill under the configured skills directory (or report that none are configured); with `<name> [prompt]`, load `<skills_dir>/<name>/SKILL.md` (or an absolute path when no skills dir is set) and send its contents prefixed to the optional `prompt` as the next user turn
- `/models` — query the `/models` path on the current inference endpoint and print a plain list of supported model IDs, marking the currently configured model with a leading `* ` (Go port)
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
- `/help` — show all supported commands
@@ -117,7 +118,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size.
3. Prepare a new message list with the system prompt (`role: "system"`).
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/skill` (no argument), list every skill under the configured skills directory (or report that none is configured) and return to step 5. If starting with `/skill`, resolve the skill (a `<skills_dir>/<name>/SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
### Agentic loop (`AL(cfg, messages)`) function
@@ -156,6 +157,7 @@ If the API rejects the request with an `Invalid assistant message: content or to
- `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`)
- `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at <dir>"` to the system prompt at startup when set. The `BANTAM_TOOLS_DIR` environment variable overrides this and is checked first; if neither is set, nothing is appended. Note: this is an agent-internal hint, not forwarded to the API.)
- `bantam_skills_dir` (optional path to a directory of reusable *skills*; each skill lives in its own subdirectory as `<skill_name>/SKILL.md`. When set, the Go port appends `"Skills may be discovered and invoked from <dir>"` to the system prompt at startup, and the `/skill <skill_name> [prompt]` command loads a skill's `SKILL.md` and runs it as a prompt. The `BANTAM_SKILLS_DIR` environment variable overrides this and is checked first; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead. Note: this is an agent-internal hint, not forwarded to the API.)
The Go port also supports SOCKS5 proxying via the `SOCKS_PROXY` (or `socks_proxy`) environment variable (e.g. `SOCKS_PROXY=socks5://127.0.0.1:1080` or `SOCKS_PROXY=127.0.0.1:1080`), falling back to standard `HTTP_PROXY` / `HTTPS_PROXY` environment variables.
@@ -223,6 +225,17 @@ The `extras/` directory contains small, dependency-light shell scripts that exte
If you keep your own collection of helper scripts, point Bantam at them with the `BANTAM_TOOLS_DIR` environment variable or the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`). When either is defined (environment variable taking precedence over the config key), the Go port appends the line `Extra shell tools can be found at <dir>` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain.
## Skills
A *skill* is a reusable, self-contained instruction bundle the agent can load on demand. Each skill is a directory containing a `SKILL.md` file with the skill's prompt/instructions. Point Bantam at a skills directory with the `BANTAM_SKILLS_DIR` environment variable or the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`); the environment variable takes precedence. When either is set, the Go port appends `Skills may be discovered and invoked from <dir>` to the system prompt at startup, and the agent can run a skill with:
```text
/skill # list every skill under the configured skills directory
/skill <name> [prompt] # loads <skills_dir>/<name>/SKILL.md and runs it as a prompt
```
A bare `/skill` (no name) lists all skills found under the configured skills directory — a subdirectory is treated as a skill only if it contains a `SKILL.md` file — or reports that no skills directory is configured. The contents of `SKILL.md` are prefixed to the optional `prompt` and sent as the next user turn. If neither `BANTAM_SKILLS_DIR` nor `bantam_skills_dir` is set, `/skill <name>` accepts an absolute path instead: either a skill directory (e.g. `/abs/path/to/skill`, which resolves to `/abs/path/to/skill/SKILL.md`) or a direct path to a `SKILL.md` file.
### `extras/websearch`
@@ -309,6 +322,10 @@ The default system prompt instructs the agent to respect `AGENTS.md` contents in
Set the `BANTAM_TOOLS_DIR` environment variable (or the `bantam_tools_dir` key in the config file — `.bantam.cfg` if present, else `model.cfg`) to a directory containing your helper scripts. When defined, the Go port appends `Extra shell tools can be found at <dir>` to the system prompt at startup, making the agent aware of them. The environment variable takes precedence over the config key; if neither is set, nothing is appended.
### How do I give Bantam reusable skills?
Set the `BANTAM_SKILLS_DIR` environment variable (or the `bantam_skills_dir` key in the config file — `.bantam.cfg` if present, else `model.cfg`) to a directory where each subdirectory is a skill containing a `SKILL.md` file. When defined, the Go port appends `Skills may be discovered and invoked from <dir>` to the system prompt at startup, and you (or the agent) can run a skill with `/skill <name> [prompt]`. The environment variable takes precedence over the config key; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead.
### Is there any common config place for Bantam?
No, loading the config file (`.bantam.cfg` if present, else `model.cfg`) is deliberately only supported from the current working directory. This allows natural separation of configs per project. In case there's no config file inside the project, Bantam will use the `openrouter/free` model from Kilo Code with the temperature 0.7.
+94 -1
View File
@@ -56,7 +56,8 @@ type Cfg struct {
func internalKey(k string) bool {
switch k {
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
"shell_timeout", "max_al_iterations", "color", "context_window":
"shell_timeout", "max_al_iterations", "color", "context_window",
"bantam_tools_dir", "bantam_skills_dir":
return true
}
return false
@@ -306,6 +307,18 @@ func toolsDir(cfg *Cfg) string {
return ""
}
// skillsDir returns the directory Bantam should scan for skills, preferring the
// BANTAM_SKILLS_DIR environment variable, then the bantam_skills_dir config key.
func skillsDir(cfg *Cfg) string {
if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" {
return v
}
if v := strings.TrimSpace(cfg.Raw["bantam_skills_dir"]); v != "" {
return v
}
return ""
}
func c(t string, cs ...int) string {
if !COL || len(cs) == 0 { return t }
s := make([]string, len(cs))
@@ -1801,6 +1814,71 @@ func readLine(prompt string) (string, bool) {
}
}
// skillPrompt resolves and composes the prompt for the /skill command: it reads
// <dir>/<name>/SKILL.md (or an absolute path when no skills directory is set) and
// prefixes its contents to the optional user prompt.
func skillPrompt(u string, cfg *Cfg) (string, error) {
rest := strings.TrimSpace(strings.TrimPrefix(u, "/skill"))
if rest == "" {
return "", fmt.Errorf("usage: /skill <skill_name|absolute_path> [prompt]")
}
fields := strings.SplitN(rest, " ", 2)
name := fields[0]
prompt := ""
if len(fields) == 2 {
prompt = strings.TrimSpace(fields[1])
}
sd := skillsDir(cfg)
var path string
if sd != "" {
path = filepath.Join(sd, name, "SKILL.md")
} else if strings.HasSuffix(name, "SKILL.md") {
path = name // absolute path to the SKILL.md file itself
} else {
path = filepath.Join(name, "SKILL.md") // absolute path to the skill directory
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("cannot read skill %q: %v", name, err)
}
content := strings.TrimRight(string(data), "\r\n")
if prompt != "" {
return content + "\n\n" + prompt, nil
}
return content, nil
}
// listSkills prints every skill found under the configured skills directory, or a
// clear notice when none is configured / none are present.
func listSkills(cfg *Cfg) {
sd := skillsDir(cfg)
if sd == "" {
fmt.Println(c("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).", 33))
return
}
entries, err := os.ReadDir(sd)
if err != nil {
fmt.Println(c("[skill error: cannot read skills dir: "+err.Error()+"]", 31))
return
}
var names []string
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(filepath.Join(sd, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
}
if len(names) == 0 {
fmt.Println(c("No skills found in "+sd, 33))
return
}
fmt.Println(c("Available skills in "+sd+":", 1, 36))
for _, n := range names {
fmt.Println(c(" "+n, 32))
}
}
func runDirectShell(cmd string, timeout int) {
cmd = filterText(strings.TrimSpace(cmd))
if cmd == "" { return }
@@ -1844,6 +1922,9 @@ func main() {
if td := toolsDir(&cfg); td != "" {
sp += "\n\nExtra shell tools can be found at " + td
}
if sd := skillsDir(&cfg); sd != "" {
sp += "\n\nSkills may be discovered and invoked from " + sd
}
COL = col(cfg)
stdin = bufio.NewReader(os.Stdin)
msgs := []Message{{Role: "system", Content: strp(sp)}}
@@ -1987,6 +2068,17 @@ func main() {
fmt.Println(c("Usage: /cfg <param> [val]", 31))
}
continue
case strings.HasPrefix(u, "/skill"):
if strings.TrimSpace(strings.TrimPrefix(u, "/skill")) == "" {
listSkills(&cfg) // bare /skill lists available skills
continue
}
composed, err := skillPrompt(u, &cfg)
if err != nil {
fmt.Println(c("[skill error: "+err.Error()+"]", 31))
continue
}
u = composed // fall through to a normal LLM turn with the composed prompt
case u == "/models":
ml, err := listModels(&cfg)
if err != nil {
@@ -2014,6 +2106,7 @@ func main() {
{"/endpoint [e]", "alias for /cfg endpoint"},
{"!<cmd>", "run shell command directly"},
{"/models", "list models at endpoint"},
{"/skill <n> [p]", "load SKILL.md and run as prompt"},
{"/help", "show help"},
} {
fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1])
+108
View File
@@ -2525,3 +2525,111 @@ func TestWriteFileOmittedOverwritesWholeFile(t *testing.T) {
}
// #18: skillsDir mirrors toolsDir, and skillPrompt composes SKILL.md + prompt.
func TestSkills(t *testing.T) {
base := t.TempDir()
skillDir := filepath.Join(base, "skills")
if err := os.MkdirAll(filepath.Join(skillDir, "greet"), 0755); err != nil {
t.Fatal(err)
}
skillMd := "You are a friendly greeter.\n"
if err := os.WriteFile(filepath.Join(skillDir, "greet", "SKILL.md"), []byte(skillMd), 0644); err != nil {
t.Fatal(err)
}
// skillsDir prefers the env var, then the config key.
t.Setenv("BANTAM_SKILLS_DIR", skillDir)
if got := skillsDir(&Cfg{}); got != skillDir {
t.Errorf("skillsDir() with env = %q, want %q", got, skillDir)
}
t.Setenv("BANTAM_SKILLS_DIR", "")
if got := skillsDir(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); got != skillDir {
t.Errorf("skillsDir() with config = %q, want %q", got, skillDir)
}
// With a skills dir set, the name is resolved relative to it.
got, err := skillPrompt("/skill greet hello there", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
if err != nil {
t.Fatalf("skillPrompt(relative): %v", err)
}
want := skillMd + "\nhello there"
if got != want {
t.Errorf("skillPrompt(relative) = %q, want %q", got, want)
}
// With no skills dir, the name is an absolute path to the skill directory.
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet")+" just hi", &Cfg{})
if err != nil {
t.Fatalf("skillPrompt(absolute): %v", err)
}
if got != skillMd+"\njust hi" {
t.Errorf("skillPrompt(absolute) = %q, want %q", got, skillMd+"\njust hi")
}
// Absolute path directly to a SKILL.md file also works.
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet", "SKILL.md"), &Cfg{})
if err != nil {
t.Fatalf("skillPrompt(file): %v", err)
}
if got != strings.TrimRight(skillMd, "\n") {
t.Errorf("skillPrompt(file) = %q, want %q", got, strings.TrimRight(skillMd, "\n"))
}
// Missing skill reports an error; bare /skill reports usage.
if _, err := skillPrompt("/skill nope", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); err == nil {
t.Error("skillPrompt(missing) expected error, got nil")
}
if _, err := skillPrompt("/skill", &Cfg{}); err == nil {
t.Error("skillPrompt(bare) expected error, got nil")
}
}
// #19: bare /skill lists skills under the configured dir, or reports none.
func TestListSkills(t *testing.T) {
base := t.TempDir()
skillDir := filepath.Join(base, "skills")
if err := os.MkdirAll(filepath.Join(skillDir, "alpha"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(skillDir, "beta"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "alpha", "SKILL.md"), []byte("a"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "beta", "SKILL.md"), []byte("b"), 0644); err != nil {
t.Fatal(err)
}
// a directory without SKILL.md must NOT be reported as a skill
if err := os.MkdirAll(filepath.Join(skillDir, "notaskill"), 0755); err != nil {
t.Fatal(err)
}
capture := func() string {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
listSkills(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
w.Close()
os.Stdout = old
data, _ := io.ReadAll(r)
return string(data)
}
out := capture()
if !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") {
t.Errorf("listSkills missing skills, got:\n%s", out)
}
if strings.Contains(out, "notaskill") {
t.Errorf("listSkills reported a non-skill directory, got:\n%s", out)
}
// No skills dir configured -> clear notice, no panic.
os.Stdout, _ = os.Open(os.DevNull)
listSkills(&Cfg{})
os.Stdout.Close()
}