added /models cmd
This commit is contained in:
@@ -66,6 +66,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current
|
|||||||
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
|
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
|
||||||
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
|
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
|
||||||
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
|
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
|
||||||
|
- `/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)
|
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
|
||||||
- `/help` — show all supported commands
|
- `/help` — show all supported commands
|
||||||
- `/clear` — reset the conversation to just the system prompt
|
- `/clear` — reset the conversation to just the system prompt
|
||||||
@@ -112,7 +113,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
|||||||
2. Read model parameters from `model.cfg` (`key=value` format) and discover context window size.
|
2. Read model parameters from `model.cfg` (`key=value` format) and discover context window size.
|
||||||
3. Prepare a new message list with the system prompt (`role: "system"`).
|
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.
|
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 equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries 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 `model.cfg` live (`/cfg <param> <val>`) and 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/autosave.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 equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries 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 `model.cfg` live (`/cfg <param> <val>`) and return to step 5. 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/autosave.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.
|
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
|
### Agentic loop (`AL(cfg, messages)`) function
|
||||||
|
|||||||
@@ -136,6 +136,49 @@ func fetchContextWindow(cfg *Cfg) int {
|
|||||||
return 262144
|
return 262144
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listModels queries the /models path on the configured inference endpoint and
|
||||||
|
// returns a plain newline-separated list of supported model IDs. The currently
|
||||||
|
// configured model is marked with a leading "* ". On transport or HTTP errors a
|
||||||
|
// non-nil error is returned so the caller can surface it.
|
||||||
|
func listModels(cfg *Cfg) (string, error) {
|
||||||
|
t := cfg.Timeout
|
||||||
|
if t < 10 { t = 10 }
|
||||||
|
client := &http.Client{Timeout: time.Duration(t) * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
|
||||||
|
if err != nil { return "", err }
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Bantam/1.0)")
|
||||||
|
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil { return "", err }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", fmt.Errorf("HTTP %d from /models", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
Models []map[string]any `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse /models response: %v", err)
|
||||||
|
}
|
||||||
|
list := res.Data
|
||||||
|
if len(list) == 0 { list = res.Models }
|
||||||
|
if len(list) == 0 {
|
||||||
|
return "No models returned by the endpoint.", nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, item := range list {
|
||||||
|
id, _ := item["id"].(string)
|
||||||
|
if id == "" { continue }
|
||||||
|
if id == cfg.Model || strings.EqualFold(id, cfg.Model) {
|
||||||
|
b.WriteString("* " + id + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + id + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func getCfg(path string) Cfg {
|
func getCfg(path string) Cfg {
|
||||||
cfg := defCfg
|
cfg := defCfg
|
||||||
cfg.Raw = map[string]string{
|
cfg.Raw = map[string]string{
|
||||||
@@ -1042,6 +1085,14 @@ func last(msgs []Message) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lastRole returns the role of the last message in the conversation, or "" if
|
||||||
|
// it is empty. It is used to detect when a turn ended on a tool result rather
|
||||||
|
// than a normal assistant reply, so the conversation can be auto-continued.
|
||||||
|
func lastRole(msgs []Message) string {
|
||||||
|
if len(msgs) == 0 { return "" }
|
||||||
|
return msgs[len(msgs)-1].Role
|
||||||
|
}
|
||||||
|
|
||||||
func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, Usage, error) {
|
func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, Usage, error) {
|
||||||
done := false
|
done := false
|
||||||
var turnUsage Usage
|
var turnUsage Usage
|
||||||
@@ -1545,6 +1596,11 @@ func main() {
|
|||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
var usg Usage
|
var usg Usage
|
||||||
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
|
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
|
||||||
|
if lastRole(msgs) == "tool" {
|
||||||
|
msgs = append(msgs, Message{Role: "user", Content: strp("continue")})
|
||||||
|
fmt.Println(c("[auto continue: last message was a tool result]", 33))
|
||||||
|
msgs, usg, err = AL(sigCtx, &cfg, msgs, sp, 0)
|
||||||
|
}
|
||||||
interrupted := sigCtx.Err() != nil
|
interrupted := sigCtx.Err() != nil
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1638,12 +1694,21 @@ func main() {
|
|||||||
fmt.Println(c("Usage: /cfg <param> [val]", 31))
|
fmt.Println(c("Usage: /cfg <param> [val]", 31))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
case u == "/models":
|
||||||
|
ml, err := listModels(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(c("[error querying /models: "+err.Error()+"]", 31))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(c("Supported models at "+cfg.Endpoint+":", 1, 36))
|
||||||
|
fmt.Print(renderMD(ml))
|
||||||
|
continue
|
||||||
case strings.HasPrefix(u, "!"):
|
case strings.HasPrefix(u, "!"):
|
||||||
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
continue
|
continue
|
||||||
case u == "/help":
|
case u == "/help":
|
||||||
fmt.Println(c("Bantam commands:", 1, 36))
|
fmt.Println(c("Bantam commands:", 1, 36))
|
||||||
for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load <id>", "load session"}, {"/compact", "compact context"}, {"/cfg <k> [v]", "get/set config"}, {"!<cmd>", "run shell command directly"}, {"/help", "show help"}} {
|
for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load <id>", "load session"}, {"/compact", "compact context"}, {"/cfg <k> [v]", "get/set config"}, {"!<cmd>", "run shell command directly"}, {"/models", "list models at endpoint"}, {"/help", "show help"}} {
|
||||||
fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1])
|
fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1])
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -1652,6 +1717,11 @@ func main() {
|
|||||||
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
|
turnMsgs = append(turnMsgs, Message{Role: "user", Content: strp(u)})
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs, sp, 0)
|
resMsgs, usg, err := AL(sigCtx, &cfg, turnMsgs, sp, 0)
|
||||||
|
if lastRole(resMsgs) == "tool" {
|
||||||
|
resMsgs = append(resMsgs, Message{Role: "user", Content: strp("continue")})
|
||||||
|
fmt.Println(c("[auto continue: last message was a tool result]", 33))
|
||||||
|
resMsgs, usg, err = AL(sigCtx, &cfg, resMsgs, sp, 0)
|
||||||
|
}
|
||||||
interrupted := sigCtx.Err() != nil
|
interrupted := sigCtx.Err() != nil
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1845,6 +1845,65 @@ func TestFormatUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListModels(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/models" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{
|
||||||
|
"data": [
|
||||||
|
{"id": "alpha"},
|
||||||
|
{"id": "my-target-model"},
|
||||||
|
{"id": "beta"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := defCfg
|
||||||
|
cfg.Endpoint = srv.URL
|
||||||
|
cfg.Model = "my-target-model"
|
||||||
|
cfg.APIKey = "secret"
|
||||||
|
|
||||||
|
out, err := listModels(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
var got []string
|
||||||
|
for _, l := range lines {
|
||||||
|
if l == "" { continue }
|
||||||
|
got = append(got, l)
|
||||||
|
}
|
||||||
|
want := []string{" alpha", "* my-target-model", " beta"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), out)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("line %d: expected %q, got %q", i, want[i], got[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListModelsHTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
cfg := defCfg
|
||||||
|
cfg.Endpoint = srv.URL
|
||||||
|
cfg.APIKey = "-"
|
||||||
|
if _, err := listModels(&cfg); err == nil {
|
||||||
|
t.Fatalf("expected error on HTTP 500, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchContextWindowFromModelsAPI(t *testing.T) {
|
func TestFetchContextWindowFromModelsAPI(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/models" {
|
if r.URL.Path != "/models" {
|
||||||
|
|||||||
Reference in New Issue
Block a user