Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a936e850d | ||
|
|
b8686c82ab |
@@ -124,8 +124,9 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
|
|||||||
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
|
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
|
||||||
3. If there are pending `tool_calls` in the assistant response:
|
3. If there are pending `tool_calls` in the assistant response:
|
||||||
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
- For each tool call, output a trace log (`[tool call: name(args)]`).
|
||||||
- Validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
- Sanitize tool arguments to filter out non-printable and space-like Unicode characters (protecting against indirect prompt injection), and validate JSON arguments. If invalid, format a tool-error response so the LLM can self-correct.
|
||||||
- Execute tool action (`shell_exec` or `run_subagent`).
|
- Execute tool action (`shell_exec` or `run_subagent`).
|
||||||
|
- Sanitize the tool result output to strip any non-printable and space-like Unicode characters (leaving only ASCII space, tab, newline, and printable Unicode characters).
|
||||||
- Output a trace log of the result (`[tool result: name]`).
|
- Output a trace log of the result (`[tool result: name]`).
|
||||||
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
- Append tool result message (`role: "tool"`, `tool_call_id`, `content`: result string) to `messages`.
|
||||||
- Loop back to step 1.
|
- Loop back to step 1.
|
||||||
@@ -173,6 +174,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
|
- Full agentic loop: LLM calls, `shell_exec` / `run_subagent` tool execution with JSON argument validation (invalid args are fed back so the model can self-correct), and the 5-level subagent recursion depth limit
|
||||||
|
- Indirect prompt injection defense: sanitizes tool parameters and tool outputs by filtering non-printable and space-like Unicode characters, preserving standard space, tab, newline, and printable Unicode characters
|
||||||
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
|
- A `...requesting...` in-flight indicator: in-place on a TTY (`\r` overwrite, erased on completion), a plain line when output is piped
|
||||||
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
|
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
|
||||||
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
|
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -702,21 +703,30 @@ type streamDelta struct {
|
|||||||
func cleanMessagesForLLM(msgs []Message) []Message {
|
func cleanMessagesForLLM(msgs []Message) []Message {
|
||||||
out := make([]Message, len(msgs))
|
out := make([]Message, len(msgs))
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
out[i] = Message{
|
out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID}
|
||||||
Role: m.Role,
|
|
||||||
Content: m.Content,
|
|
||||||
ToolCalls: m.ToolCalls,
|
|
||||||
ToolCallID: m.ToolCallID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterText(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
for _, r := range s {
|
||||||
|
if r == ' ' || r == '\t' || r == '\n' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else if !unicode.Is(unicode.Z, r) && !unicode.IsControl(r) && !unicode.Is(unicode.C, r) && unicode.IsPrint(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeMessages(msgs []Message) {
|
func sanitizeMessages(msgs []Message) {
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
|
||||||
for j := range msgs[i].ToolCalls {
|
for j := range msgs[i].ToolCalls {
|
||||||
tc := &msgs[i].ToolCalls[j]
|
tc := &msgs[i].ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||||
@@ -724,6 +734,8 @@ func sanitizeMessages(msgs []Message) {
|
|||||||
tc.Function.Arguments = string(fixed)
|
tc.Function.Arguments = string(fixed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if msgs[i].Role == "tool" && msgs[i].Content != nil {
|
||||||
|
msgs[i].Content = strp(filterText(*msgs[i].Content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,12 +968,13 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shell(ctx context.Context, cmd string, timeout int) string {
|
func shell(ctx context.Context, cmd string, timeout int) string {
|
||||||
|
cmd = filterText(cmd)
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
|
||||||
c.WaitDelay = 100 * time.Millisecond
|
c.WaitDelay = 100 * time.Millisecond
|
||||||
out, err := c.CombinedOutput()
|
out, err := c.CombinedOutput()
|
||||||
res := strings.TrimSpace(string(out))
|
res := strings.TrimSpace(filterText(string(out)))
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "[interrupted]\n\nexit: -1"
|
return "[interrupted]\n\nexit: -1"
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1031,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
|
if u.Cached() > 0 { turnUsage.CachedTokens = u.Cached() }
|
||||||
for j := range m.ToolCalls {
|
for j := range m.ToolCalls {
|
||||||
tc := &m.ToolCalls[j]
|
tc := &m.ToolCalls[j]
|
||||||
|
tc.Function.Arguments = filterText(tc.Function.Arguments)
|
||||||
astr := tc.Function.Arguments
|
astr := tc.Function.Arguments
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil {
|
||||||
@@ -1035,7 +1049,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
if len(m.ToolCalls) == 0 { done = true; break }
|
if len(m.ToolCalls) == 0 { done = true; break }
|
||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||||
fn, astr := tc.Function.Name, tc.Function.Arguments
|
fn, astr := tc.Function.Name, filterText(tc.Function.Arguments)
|
||||||
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
fmt.Println(c(fmt.Sprintf("[tool call: %s(%s)]", fn, astr), 33))
|
||||||
res, sty := "", 2
|
res, sty := "", 2
|
||||||
var a map[string]any
|
var a map[string]any
|
||||||
@@ -1045,10 +1059,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
switch fn {
|
switch fn {
|
||||||
case "shell_exec":
|
case "shell_exec":
|
||||||
cmd, _ := a["command"].(string)
|
cmd, _ := a["command"].(string)
|
||||||
|
cmd = filterText(cmd)
|
||||||
res = shell(ctx, cmd, cfg.ShellTimeout)
|
res = shell(ctx, cmd, cfg.ShellTimeout)
|
||||||
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
if err := ctx.Err(); err != nil { return msgs, turnUsage, err }
|
||||||
case "run_subagent":
|
case "run_subagent":
|
||||||
pr, _ := a["prompt"].(string)
|
pr, _ := a["prompt"].(string)
|
||||||
|
pr = filterText(pr)
|
||||||
if depth >= MAX_DEPTH {
|
if depth >= MAX_DEPTH {
|
||||||
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
res, sty = fmt.Sprintf("[subagent depth limit (%d) reached, child not spawned]", MAX_DEPTH), 31
|
||||||
} else {
|
} else {
|
||||||
@@ -1064,13 +1080,14 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message, sp string, depth int) ([]
|
|||||||
} else {
|
} else {
|
||||||
turnUsage.CompletionTokens += subu.CompletionTokens
|
turnUsage.CompletionTokens += subu.CompletionTokens
|
||||||
turnUsage.TotalTokens += subu.TotalTokens
|
turnUsage.TotalTokens += subu.TotalTokens
|
||||||
res, sty = last(subr), 2
|
res, sty = filterText(last(subr)), 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
res, sty = "Unknown tool: " + fn, 31
|
res, sty = "Unknown tool: " + fn, 31
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
res = filterText(res)
|
||||||
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
fmt.Println(c("[tool result: "+fn+"]", 32) + "\n" + c(res, sty) + "\n")
|
||||||
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
msgs = append(msgs, Message{Role: "tool", ToolCallID: tc.ID, Content: strp(res)})
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1423,42 @@ func readLine(prompt string) (string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDirectShell(cmd string, timeout int) {
|
||||||
|
cmd = filterText(strings.TrimSpace(cmd))
|
||||||
|
if cmd == "" { return }
|
||||||
|
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
||||||
|
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
||||||
|
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
res := shell(sigCtx, cmd, timeout)
|
||||||
|
cancel()
|
||||||
|
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func doCompact(cfg *Cfg, msgs []Message) []Message {
|
||||||
|
if len(msgs) <= 1 {
|
||||||
|
fmt.Println(c("Nothing to compact yet.", 33))
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
fmt.Println(c("[compacting conversation...]", 33))
|
||||||
|
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
nm, sm, err := compact(sigCtx, cfg, msgs)
|
||||||
|
interrupted := sigCtx.Err() != nil
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if interrupted || errors.Is(err, context.Canceled) {
|
||||||
|
fmt.Println(c("\n[interrupted]", 33))
|
||||||
|
} else {
|
||||||
|
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
msgs = nm
|
||||||
|
autosave(msgs)
|
||||||
|
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
||||||
|
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
sp := prompt("system.txt")
|
sp := prompt("system.txt")
|
||||||
cfg := getCfg("model.cfg")
|
cfg := getCfg("model.cfg")
|
||||||
@@ -1422,15 +1475,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
u := strings.TrimSpace(string(data))
|
u := strings.TrimSpace(string(data))
|
||||||
if strings.HasPrefix(u, "!") {
|
if strings.HasPrefix(u, "!") {
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
if cmd != "" {
|
|
||||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
|
||||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
|
||||||
cancel()
|
|
||||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
msgs = append(msgs, Message{Role: "user", Content: strp(u)})
|
||||||
@@ -1503,27 +1548,7 @@ func main() {
|
|||||||
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
fmt.Println(c("[session loaded: "+parts[1]+"]", 32) + " " + c(summary(msgs), 2))
|
||||||
continue
|
continue
|
||||||
case u == "/compact":
|
case u == "/compact":
|
||||||
if len(msgs) <= 1 {
|
msgs = doCompact(&cfg, msgs)
|
||||||
fmt.Println(c("Nothing to compact yet.", 33))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Println(c("[compacting conversation...]", 33))
|
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
|
||||||
interrupted := sigCtx.Err() != nil
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
if interrupted || errors.Is(err, context.Canceled) {
|
|
||||||
fmt.Println(c("\n[interrupted]", 33))
|
|
||||||
} else {
|
|
||||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msgs = nm
|
|
||||||
autosave(msgs)
|
|
||||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
|
||||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "/cfg"):
|
case strings.HasPrefix(u, "/cfg"):
|
||||||
parts := strings.SplitN(u, " ", 3)
|
parts := strings.SplitN(u, " ", 3)
|
||||||
@@ -1551,15 +1576,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
case strings.HasPrefix(u, "!"):
|
case strings.HasPrefix(u, "!"):
|
||||||
cmd := strings.TrimSpace(strings.TrimPrefix(u, "!"))
|
runDirectShell(strings.TrimPrefix(u, "!"), cfg.ShellTimeout)
|
||||||
if cmd != "" {
|
|
||||||
astr, _ := json.Marshal(map[string]string{"command": cmd})
|
|
||||||
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
|
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
res := shell(sigCtx, cmd, cfg.ShellTimeout)
|
|
||||||
cancel()
|
|
||||||
fmt.Println(c("[tool result: shell_exec]", 32) + "\n" + c(res, 2))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
case u == "/help":
|
case u == "/help":
|
||||||
fmt.Println(c("Bantam commands:", 1, 36))
|
fmt.Println(c("Bantam commands:", 1, 36))
|
||||||
@@ -1588,27 +1605,10 @@ func main() {
|
|||||||
pct := float64(usg.PromptTokens) * 100.0 / float64(cfg.ContextWindow)
|
pct := float64(usg.PromptTokens) * 100.0 / float64(cfg.ContextWindow)
|
||||||
if pct >= 60.0 && len(msgs) > 1 {
|
if pct >= 60.0 && len(msgs) > 1 {
|
||||||
fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, usg.PromptTokens, cfg.ContextWindow), 33))
|
fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, usg.PromptTokens, cfg.ContextWindow), 33))
|
||||||
ans, ok := readPlain("")
|
if ans, ok := readPlain(""); ok {
|
||||||
if ok {
|
|
||||||
ans = strings.TrimSpace(strings.ToLower(ans))
|
ans = strings.TrimSpace(strings.ToLower(ans))
|
||||||
if ans == "" || ans == "y" || ans == "yes" {
|
if ans == "" || ans == "y" || ans == "yes" {
|
||||||
fmt.Println(c("[compacting conversation...]", 33))
|
msgs = doCompact(&cfg, msgs)
|
||||||
sigCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
nm, sm, err := compact(sigCtx, &cfg, msgs)
|
|
||||||
interrupted := sigCtx.Err() != nil
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
if interrupted || errors.Is(err, context.Canceled) {
|
|
||||||
fmt.Println(c("\n[interrupted]", 33))
|
|
||||||
} else {
|
|
||||||
fmt.Println(c("[compact failed: "+err.Error()+"]", 31))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msgs = nm
|
|
||||||
autosave(msgs)
|
|
||||||
fmt.Println(c(fmt.Sprintf("[compacted to %d messages]", len(msgs)), 32))
|
|
||||||
fmt.Println(c("--- summary ---", 33) + "\n" + c(sm, 2))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1851,6 +1851,95 @@ func TestFetchContextWindowFallbackConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterText(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ASCII printable, spaces, tabs, newlines",
|
||||||
|
input: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||||
|
expected: "Hello World!\t123\nLine 2 ~`@#$%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CRLF normalization",
|
||||||
|
input: "line1\r\nline2\r\n",
|
||||||
|
expected: "line1\nline2\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unicode printable letters, numbers, punctuation",
|
||||||
|
input: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||||
|
expected: "こんにちは世界! Привет мир! 123 αβγ €$¥",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Control characters stripped",
|
||||||
|
input: "null\x00bell\x07esc\x1b[31mred\x1b[0m\x7fdel",
|
||||||
|
expected: "nullbellesc[31mred[0mdel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zero-width and format characters stripped",
|
||||||
|
input: "hidden\u200Binjection\u200Cand\u200Djoiner\uFEFFbom\u202Ebidi\U000E0001tag",
|
||||||
|
expected: "hiddeninjectionandjoinerbombiditag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Space-like unicode characters stripped",
|
||||||
|
input: "nbsp\u00A0space\u2000enquad\u2001emquad\u2009thin\u202Fnarrow\u3000ideo\u1680ogham\u2028lsep\u2029psep",
|
||||||
|
expected: "nbspspaceenquademquadthinnarrowideooghamlseppsep",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := filterText(tc.input)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("filterText(%q) = %q, expected %q", tc.input, got, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeMessagesWithInvisibles(t *testing.T) {
|
||||||
|
msgs := []Message{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}{
|
||||||
|
Name: "shell_exec",
|
||||||
|
Arguments: "{\"command\": \"cat\u200B \u00A0file.txt\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: "call_1",
|
||||||
|
Content: strp("output\u200B\x00with\u00A0invisible\r\nexit: 0"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeMessages(msgs)
|
||||||
|
|
||||||
|
tcArgs := msgs[0].ToolCalls[0].Function.Arguments
|
||||||
|
if strings.Contains(tcArgs, "\u200B") || strings.Contains(tcArgs, "\u00A0") {
|
||||||
|
t.Errorf("Tool call arguments still contain invisible characters: %q", tcArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolContent := *msgs[1].Content
|
||||||
|
if strings.Contains(toolContent, "\u200B") || strings.Contains(toolContent, "\x00") || strings.Contains(toolContent, "\u00A0") || strings.Contains(toolContent, "\r") {
|
||||||
|
t.Errorf("Tool content still contains invisible characters: %q", toolContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(toolContent, "outputwithinvisible\nexit: 0") {
|
||||||
|
t.Errorf("Tool content unexpected: %q", toolContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +1,92 @@
|
|||||||
#!/usr/bin/env perl
|
#!/usr/bin/env perl
|
||||||
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
# MicroBantam (mb): the Bantam agent in <100 SLOC - readable, core modules only
|
||||||
# Created by Luxferre in 2026, released into the public domain
|
# Created by Luxferre in 2026, released into the public domain
|
||||||
|
|
||||||
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path);
|
use strict; use warnings; use HTTP::Tiny; use JSON::PP; use POSIX qw(strftime); use File::Path qw(make_path);
|
||||||
|
|
||||||
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
|
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /^Use of uninitialized value \$err in numeric eq \(==\) at .*IO\/Socket\/IP\.pm line \d+/ };
|
||||||
|
|
||||||
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
|
binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffered output in UTF-8
|
||||||
|
|
||||||
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- run_subagent: delegate a sub-task to a child agent; returns its reply.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening braces in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.";
|
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- run_subagent: delegate a sub-task to a child agent; returns its reply.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening braces in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.";
|
||||||
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
|
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
|
||||||
|
|
||||||
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
|
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
|
||||||
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
|
||||||
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq q{-} && $ENV{OPENAI_API_KEY};
|
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
|
||||||
\%d; }
|
sub sp { my $p = ''; if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; } $p =~ s/^\s+|\s+$//g; length($p) ? $p : $DEF_SP }
|
||||||
|
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}]//g; $s }
|
||||||
sub sp { my $p = '';
|
|
||||||
if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; }
|
|
||||||
$p =~ s/^\s+|\s+$//g;
|
|
||||||
length($p) ? $p : $DEF_SP; }
|
|
||||||
|
|
||||||
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
|
||||||
|
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
|
||||||
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH' && $m->{tool_calls}) { for my $tc (@{$m->{tool_calls}}) { my $a = eval { decode_json($tc->{function}{arguments} // '{}') }; $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; } } } }
|
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||||
|
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||||
sub llm { my ($c, $msgs) = @_; # one non-streaming chat completion
|
$tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}) if ref $a ne 'HASH'; }
|
||||||
sanitize_msgs($msgs);
|
$m->{content} = filter_text($m->{content}) if ($m->{role} // '') eq 'tool' && defined $m->{content};
|
||||||
|
} } }
|
||||||
|
sub llm { my ($c, $msgs) = @_; sanitize_msgs($msgs);
|
||||||
my $ep = $c->{endpoint}; $ep =~ s{/+$}{};
|
my $ep = $c->{endpoint}; $ep =~ s{/+$}{};
|
||||||
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
my $h = {'Content-Type'=>'application/json', 'User-Agent'=>'Mozilla/5.0 (compatible; MicroBantam/1.0)'};
|
||||||
$h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-';
|
$h->{Authorization} = "Bearer $c->{api_key}" if $c->{api_key} ne '-';
|
||||||
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], model=>$c->{model}, temperature=>0+$c->{temperature});
|
my %p = (messages=>$msgs, tools=>[T('shell_exec', 'Run a shell command, return output and exit code.', {command=>{type=>'string'}}), T('run_subagent', 'Run a child agent with a prompt.', {prompt=>{type=>'string'}})], model=>$c->{model}, temperature=>0+$c->{temperature});
|
||||||
for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; }
|
for my $k (keys %$c) { next if $k =~ /^(endpoint|api_key|timeout|shell_timeout|max_al_iterations|stream|color)$/; my $val = eval { decode_json($c->{$k}) }; $p{$k} = defined $val ? $val : $c->{$k}; }
|
||||||
my $body = encode_json(\%p); my $tty = -t STDOUT;
|
my $tty = -t STDOUT; print $tty ? "\r...requesting..." : "...requesting...\n";
|
||||||
print $tty ? "\r...requesting..." : "...requesting...\n";
|
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>encode_json(\%p)});
|
||||||
my $r = HTTP::Tiny->new(timeout=>0+$c->{timeout})->post("$ep/chat/completions", {headers=>$h, content=>$body});
|
print "\r\e[K" if $tty;
|
||||||
print "\r\e[K" if $tty; # clear the spinner line
|
|
||||||
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
my $d = $r->{success} ? eval { decode_json($r->{content}) } : undef;
|
||||||
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
return $d->{choices}[0]{message} if $d && $d->{choices} && @{$d->{choices}};
|
||||||
my $rb = $r->{content} // '';
|
my $rb = substr($r->{content} // '', 0, 500);
|
||||||
$rb = substr($rb, 0, 500) if length($rb) > 500;
|
|
||||||
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
die "API error: " . (length($rb) ? "$rb (HTTP $r->{status})" : ($r->{reason} || "HTTP $r->{status}")) . "\n"; }
|
||||||
|
sub shell_exec { my ($cmd, $t, $out) = (filter_text($_[0]), $_[1], '');
|
||||||
sub shell_exec { my ($cmd, $t) = @_; # run a command under a hard timeout
|
|
||||||
my $out = '';
|
|
||||||
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
eval { local $SIG{ALRM} = sub { alarm 0; die "timeout\n" }; alarm $t; $out = `$cmd 2>&1`; alarm 0; };
|
||||||
$out =~ s/\s+$//;
|
$out =~ s/\s+$//; utf8::decode($out); $out = filter_text($out);
|
||||||
utf8::decode($out);
|
|
||||||
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
|
$@ ? "$out\n[timeout after ${t}s]\nexit: -1" : "$out\nexit: " . ($? >> 8); }
|
||||||
|
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if ($m->{role} // '') eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
|
||||||
sub last_assistant { for my $m (reverse @{$_[0]}) { return $m->{content} if $m->{role} eq 'assistant' && defined $m->{content} && length $m->{content}; } '' }
|
sub AL { my ($c, $msgs, $sp, $depth) = ($_[0], $_[1], $_[2], $_[3] || 0);
|
||||||
|
|
||||||
sub AL { my ($c, $msgs, $sp, $depth) = @_; # the agentic loop: LLM <-> tools until done
|
|
||||||
$depth ||= 0;
|
|
||||||
for (1 .. $c->{max_al_iterations}) {
|
for (1 .. $c->{max_al_iterations}) {
|
||||||
my $m = eval { llm($c, $msgs) };
|
my $m = eval { llm($c, $msgs) };
|
||||||
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ $_->{role} eq 'assistant' } @$msgs)) {
|
if ($@ && $@ =~ /Invalid assistant message|content or tool_calls must be set/ && grep({ ($_->{role} // '') eq 'assistant' } @$msgs)) {
|
||||||
for (my $j = @$msgs - 1; $j >= 0; $j--) { if ($msgs->[$j]{role} eq 'assistant') { splice @$msgs, $j, 1; last; } }
|
for (my $j = @$msgs - 1; $j >= 0; $j--) { if (($msgs->[$j]{role} // '') eq 'assistant') { splice @$msgs, $j, 1; last; } }
|
||||||
print "[stripped malformed assistant message]\n"; redo;
|
print "[stripped malformed assistant message]\n"; redo;
|
||||||
}
|
}
|
||||||
if ($@) { print $@; return $msgs; }
|
if ($@) { print $@; return $msgs; }
|
||||||
push @$msgs, $m;
|
push @$msgs, $m;
|
||||||
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
print $m->{content}, "\n" if defined $m->{content} && length $m->{content};
|
||||||
my $tcs = $m->{tool_calls};
|
my $tcs = $m->{tool_calls}; last unless $tcs && @$tcs;
|
||||||
last unless $tcs && @$tcs;
|
|
||||||
for my $tc (@$tcs) {
|
for my $tc (@$tcs) {
|
||||||
my $fn = $tc->{function}{name};
|
my ($fn, $res) = ($tc->{function}{name});
|
||||||
|
$tc->{function}{arguments} = filter_text($tc->{function}{arguments});
|
||||||
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
my $a = eval { decode_json($tc->{function}{arguments} // '{}') };
|
||||||
my $res;
|
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: $tc->{function}{arguments}"; }
|
||||||
if (ref $a ne 'HASH') { $tc->{function}{arguments} = encode_json({invalid_raw => $tc->{function}{arguments} // ''}); $res = "bad JSON args for $fn: " . ($tc->{function}{arguments} // ''); }
|
|
||||||
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
|
elsif ($fn eq 'shell_exec') { $res = shell_exec($a->{command} // '', $c->{shell_timeout}); }
|
||||||
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>$a->{prompt} // ''}], $sp, $depth + 1)); }
|
elsif ($fn eq 'run_subagent') { $res = $depth >= 5 ? '[subagent depth limit (5) reached, child not spawned]' : filter_text(last_assistant(AL($c, [{role=>'system', content=>"$sp\n\nImportant: this is a child agent"}, {role=>'user', content=>filter_text($a->{prompt} // '')}], $sp, $depth + 1))); }
|
||||||
else { $res = "unknown tool: $fn"; }
|
else { $res = "unknown tool: $fn"; }
|
||||||
|
$res = filter_text($res);
|
||||||
print "[tool] $fn: $res\n";
|
print "[tool] $fn: $res\n";
|
||||||
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
push @$msgs, {role=>'tool', tool_call_id=>$tc->{id}, content=>$res};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$msgs; }
|
$msgs; }
|
||||||
|
sub sessions { my @s; for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; } sort { $b->{id} cmp $a->{id} } @s }
|
||||||
sub sessions { my @s; # all saved sessions, newest first
|
|
||||||
for my $f (glob "$SDIR/*.json") { open my $fh, '<', $f or next; local $/; my $d = eval { decode_json(<$fh>) }; push @s, $d if $d; }
|
|
||||||
sort { $b->{id} cmp $a->{id} } @s; }
|
|
||||||
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
|
sub sdir { make_path($SDIR) unless -d $SDIR; $SDIR }
|
||||||
sub save { sdir(); my $id = strftime('%Y%m%d-%H%M%S', localtime); my $i = 0;
|
sub save { sdir(); my ($id, $i) = (strftime('%Y%m%d-%H%M%S', localtime), 0); $id .= '-' . ++$i while -f "$SDIR/$id.json";
|
||||||
$id .= '-' . ++$i while -f "$SDIR/$id.json";
|
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!"; print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id }
|
||||||
open my $f, '>', "$SDIR/$id.json" or die "cannot save: $!";
|
sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $_[0]\n" unless $hit; $hit->{messages} }
|
||||||
print $f JSON::PP->new->utf8->pretty->encode({id=>$id, messages=>$_[0]}); close $f; $id; }
|
|
||||||
sub load { my ($want) = @_; my ($hit) = grep { $_->{id} eq $want } sessions(); die "no session: $want\n" unless $hit; $hit->{messages}; }
|
|
||||||
sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
|
sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
|
||||||
sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
|
||||||
sub set_cfg { my ($k, $v) = @_; my (@ls, $f);
|
sub set_cfg { my ($k, $v, @ls, $f) = @_;
|
||||||
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { if (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) { push @ls, "$k=$v\n"; $f = 1; } else { push @ls, $_; } } }
|
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
|
||||||
push @ls, "$k=$v\n" unless $f;
|
push @ls, "$k=$v\n" unless $f;
|
||||||
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
|
||||||
|
|
||||||
sub main {
|
sub main {
|
||||||
my ($c, $sp) = (cfg(), sp());
|
my ($c, $sp) = (cfg(), sp()); my $msgs = [{role=>'system', content=>$sp}];
|
||||||
my $msgs = [{role=>'system', content=>$sp}];
|
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
||||||
if (@ARGV) { open my $f, '<:encoding(UTF-8)', $ARGV[0] or die "cannot open $ARGV[0]: $!"; # file mode
|
|
||||||
local $/; push @$msgs, {role=>'user', content=><$f>}; AL($c, $msgs, $sp); autosave($msgs); return; }
|
|
||||||
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
print "MicroBantam ready ($c->{model}). Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n";
|
||||||
while (1) {
|
while (1) {
|
||||||
print "> "; my $u = <STDIN>; last unless defined $u;
|
print "> "; my $u = <STDIN>; last unless defined $u; $u =~ s/^\s+|\s+$//g; next unless length $u;
|
||||||
$u =~ s/^\s+|\s+$//g; next unless length $u;
|
if ($u eq '/quit') { last; }
|
||||||
if ($u eq '/quit') { last; }
|
|
||||||
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
elsif ($u eq '/clear') { $msgs = [{role=>'system', content=>$sp}]; autosave($msgs); }
|
||||||
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
elsif ($u eq '/save') { print "session saved: ", save($msgs), "\n"; }
|
||||||
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
|
elsif ($u eq '/list') { print "$_->[0] [$_->[1] msgs]\n" for list_sessions(); }
|
||||||
elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load <session id>\n"; } }
|
elsif ($u =~ /^\/load(?:\s+(\S+))?$/) { if (defined $1) { $msgs = eval { load($1) }; $@ ? print($@) : (autosave($msgs), print "loaded: $1\n"); } else { print "usage: /load <session id>\n"; } }
|
||||||
elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { if (defined $2) { set_cfg($1, $2); $c = cfg(); print "config: $1=$2\n"; } elsif (defined $1) { print exists $c->{$1} ? "$1=$c->{$1}\n" : "$1 not set\n"; } else { print "usage: /cfg <param> [val]\n"; } }
|
elsif ($u =~ /^\/cfg(?:\s+(\S+)(?:\s+(.+))?)?$/) { if (defined $2) { set_cfg($1, $2); $c = cfg(); print "config: $1=$2\n"; } elsif (defined $1) { print exists $c->{$1} ? "$1=$c->{$1}\n" : "$1 not set\n"; } else { print "usage: /cfg <param> [val]\n"; } }
|
||||||
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
elsif ($u eq '/help') { print "Commands: /quit /clear /save /list /load <id> /cfg <k> [v] /help\n"; }
|
||||||
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
|
else { push @$msgs, {role=>'user', content=>$u}; AL($c, $msgs, $sp); autosave($msgs); }
|
||||||
}
|
}
|
||||||
autosave($msgs);
|
autosave($msgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
main() unless caller();
|
main() unless caller();
|
||||||
|
|||||||
Reference in New Issue
Block a user