oc compatibility overhaul

This commit is contained in:
Luxferre
2026-09-19 00:19:25 +03:00
parent ebc62b93bf
commit e1efe70d8a
4 changed files with 567 additions and 169 deletions
+275 -70
View File
@@ -86,7 +86,7 @@ var llmTransport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext,
}
var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 15000, map[string]string{"reasoning_effort": "high"}}
var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 65536, map[string]string{"reasoning_effort": "high"}}
const opencodeAgentVersion = "opencode/1.18.31"
const opencodeProviderUA = "ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14"
@@ -94,9 +94,15 @@ const opencodeIDAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
const opencodeProjectID = "global"
// opencodeTailAlphabet is the character set used for the 14-character random
// tail of a Zen session id (real ids mix upper/lower case and digits).
// tail of OpenCode IDs.
const opencodeTailAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var (
ocMu sync.Mutex
ocLast int64
ocSeq uint64
)
func atoiD(s string, d int) int {
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
return v
@@ -414,15 +420,16 @@ func configPath() string {
return "model.cfg"
}
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:
- shell_exec: run a shell command; returns its output and exit code.
- write_file: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.
const defaultSystemPrompt = `You are Bantam, a tiny, powerful AI agent. Solve the user's task using three tools:
- bash: run a bash command; returns its output and exit code.
- read: read a file or directory from the local filesystem; returns numbered lines or directory entries.
- write: write content to a file; if offset and del_bytes are both omitted it overwrites the entire file, otherwise it writes at the given byte offset (optionally deleting bytes first); returns status.
Work 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. Stop as soon as the goal is met and report concisely: results, not process.
Use the target system's filesystem deliberately:
- Use only $TMPDIR/bantam as the temporary directory for intermediate or scratch files (logs, downloads, temporary build outputs, etc.); create it if it does not exist.
- Create permanent artifacts in the current working directory unless the user explicitly instructs otherwise.
- Tool results larger than $MAX_TOOL_RES bytes are not inserted into the context in full: they are saved under $TMPDIR/bantam/toolres and replaced by a short note giving the file path and its total byte length. Read such a file with shell_exec, paging through it in parts with byte offsets (e.g. tail -c +OFFSET <file> | head -c LENGTH) instead of loading it all at once.
- Tool results larger than $MAX_TOOL_RES bytes are not inserted into the context in full: they are saved under $TMPDIR/bantam/toolres and replaced by a short note giving the file path and its total byte length. Read such a file with the read tool, paging through it with line offsets (using offset and limit) instead of loading it all at once.
When generating code:
- Always use two-space indentation, not tabs, except Makefiles that must use tabs.
@@ -996,8 +1003,45 @@ type ToolCall struct {
}
var TOOLS = []map[string]any{
{"type": "function", "function": map[string]any{"name": "shell_exec", "description": "Run a shell command, return output and exit code.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}, "required": []string{"command"}}}},
{"type": "function", "function": map[string]any{"name": "write_file", "description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given byte offset, optionally deleting bytes first.", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}, "offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."}, "del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."}, "content": map[string]any{"type": "string"}}, "required": []string{"path", "content"}}}},
{"type": "function", "function": map[string]any{
"name": "bash",
"description": "Executes a given bash command in a shell session, returning its output and exit code.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "The command to execute"},
"workdir": map[string]any{"type": "string", "description": "The working directory to run the command in. Defaults to the current directory."},
},
"required": []string{"command"},
},
}},
{"type": "function", "function": map[string]any{
"name": "read",
"description": "Read a file or directory from the local filesystem. For files, returns numbered lines prefixed as `<line>: <content>`. For directories, returns entry names with trailing slashes for subdirectories.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"filePath": map[string]any{"type": "string", "description": "The path to the file or directory to read"},
"offset": map[string]any{"type": "integer", "description": "The line number to start reading from (1-indexed, defaults to 1)"},
"limit": map[string]any{"type": "integer", "description": "The maximum number of lines to read (defaults to 2000)"},
},
"required": []string{"filePath"},
},
}},
{"type": "function", "function": map[string]any{
"name": "write",
"description": "Write content to a file. If offset and del_bytes are both omitted, the entire file is overwritten with the new content; otherwise content is written at the given byte offset, optionally deleting bytes first.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"filePath": map[string]any{"type": "string", "description": "The path to the file to write"},
"content": map[string]any{"type": "string", "description": "The content to write to the file"},
"offset": map[string]any{"type": "integer", "description": "Byte offset to start writing from. If omitted together with del_bytes, the whole file is overwritten instead. Defaults to 0 (start of file)."},
"del_bytes": map[string]any{"type": "integer", "description": "Bytes to delete starting at offset. If omitted together with offset, the whole file is overwritten instead."},
},
"required": []string{"filePath", "content"},
},
}},
}
func strp(s string) *string { return &s }
@@ -1177,11 +1221,13 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
}
cleanMsgs := cleanMessagesForLLM(msgs)
sanitizeMessages(cleanMsgs)
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": cfg.Stream}
isOC := isOpencodeEndpoint(cfg.Endpoint)
stream := cfg.Stream || isOC
p := map[string]any{"model": cfg.Model, "temperature": cfg.Temperature, "messages": cleanMsgs, "stream": stream}
if tools != nil {
p["tools"] = tools
}
if cfg.Stream {
if stream {
p["stream_options"] = map[string]any{"include_usage": true}
}
for k, v := range cfg.Raw {
@@ -1262,7 +1308,7 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
if COL {
fmt.Print("\r\033[K")
}
if !cfg.Stream {
if !stream {
var cr struct {
Model string `json:"model"`
Choices []struct {
@@ -1545,10 +1591,17 @@ func parseStream(ctx context.Context, r io.Reader) (Message, Usage, error) {
}
func shell(ctx context.Context, cmd string, timeout int) string {
return shellWithWorkdir(ctx, cmd, "", timeout)
}
func shellWithWorkdir(ctx context.Context, cmd string, workdir string, timeout int) string {
cmd = filterText(cmd)
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
c := exec.CommandContext(cmdCtx, "sh", "-c", cmd)
c := exec.CommandContext(cmdCtx, "bash", "-c", cmd)
if workdir != "" {
c.Dir = workdir
}
c.WaitDelay = 100 * time.Millisecond
out, err := c.CombinedOutput()
res := strings.TrimSpace(filterText(string(out)))
@@ -1639,6 +1692,80 @@ func writeFile(path string, offset, delBytes int, content string) (string, error
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(contentBytes), path), nil
}
// readFileOrDir reads a file or directory for the read tool. If path is a
// directory, it lists the directory entries sorted alphabetically, appending
// a trailing slash for subdirectories. If path is a regular file, it returns
// lines between offset and offset+limit-1 (1-indexed), prefixed with line
// numbers as "<line>: <content>".
func readFileOrDir(path string, offset, limit int) (string, int) {
path = strings.TrimSpace(path)
if path == "" {
return "[tool error: read requires 'filePath' parameter]", 31
}
fi, err := os.Stat(path)
if err != nil {
return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31
}
if fi.IsDir() {
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var b strings.Builder
for _, e := range entries {
if e.IsDir() {
b.WriteString(e.Name() + "/\n")
} else {
b.WriteString(e.Name() + "\n")
}
}
return strings.TrimRight(b.String(), "\n"), 2
}
f, err := os.Open(path)
if err != nil {
return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31
}
defer f.Close()
if offset < 1 {
offset = 1
}
if limit <= 0 {
limit = 2000
}
var b strings.Builder
scanner := bufio.NewScanner(f)
buf := make([]byte, 64*1024)
scanner.Buffer(buf, 1024*1024)
lineNum := 0
linesRead := 0
for scanner.Scan() {
lineNum++
if lineNum < offset {
continue
}
linesRead++
text := scanner.Text()
if len(text) > 2000 {
text = text[:2000]
}
b.WriteString(fmt.Sprintf("%d: %s\n", lineNum, text))
if linesRead >= limit {
break
}
}
if err := scanner.Err(); err != nil && linesRead == 0 {
return fmt.Sprintf("[tool error: read %s: %v]", path, err), 31
}
return strings.TrimRight(b.String(), "\n"), 2
}
// toolResDir returns the directory where oversized tool results are spilled.
func toolResDir() string {
tmp := os.Getenv("TMPDIR")
@@ -1657,7 +1784,7 @@ func toolResDir() string {
// returned unchanged so the agent always makes progress.
func offloadToolResult(res string, maxRes int, tmps *[]string) string {
if maxRes <= 0 {
maxRes = 15000
maxRes = 65536
}
if len(res) <= maxRes {
return res
@@ -1680,7 +1807,7 @@ func offloadToolResult(res string, maxRes int, tmps *[]string) string {
return res
}
*tmps = append(*tmps, name)
return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with shell_exec; to read it partially, use a byte offset and length, e.g. `tail -c +OFFSET %s | head -c LENGTH`. The file is %d bytes long.]", n, maxRes, name, name, n)
return fmt.Sprintf("[tool result too large for context: %d bytes (limit %d). The full output was saved to %s. Read it with the read tool; to read it partially, use offset and limit. The file is %d bytes long.]", n, maxRes, name, n)
}
// cleanupTemps removes the temporary files created while offloading oversized
@@ -1777,15 +1904,70 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
res, sty = fmt.Sprintf("[tool error: invalid JSON args for %s: %v. Raw: %q]", fn, err, astr), 31
} else {
switch fn {
case "shell_exec":
case "bash", "shell_exec":
cmd, _ := a["command"].(string)
cmd = filterText(cmd)
res = shell(ctx, cmd, cfg.ShellTimeout)
workdir, _ := a["workdir"].(string)
workdir = filterText(workdir)
to := cfg.ShellTimeout
if v, ok := a["timeout"]; ok && v != nil {
switch n := v.(type) {
case float64:
if n > 0 {
if n > 1000 {
to = int(n / 1000)
} else {
to = int(n)
}
}
case int:
if n > 0 {
if n > 1000 {
to = n / 1000
} else {
to = n
}
}
}
}
res = shellWithWorkdir(ctx, cmd, workdir, to)
if err := ctx.Err(); err != nil {
return msgs, turnUsage, err
}
case "write_file":
path, _ := a["path"].(string)
case "read":
path, _ := a["filePath"].(string)
if path == "" {
path, _ = a["path"].(string)
}
path = filterText(path)
offset := 1
if v, ok := a["offset"]; ok && v != nil {
switch n := v.(type) {
case float64:
offset = int(n)
case int:
offset = n
case string:
offset = atoiD(n, 1)
}
}
limit := 2000
if v, ok := a["limit"]; ok && v != nil {
switch n := v.(type) {
case float64:
limit = int(n)
case int:
limit = n
case string:
limit = atoiD(n, 2000)
}
}
res, sty = readFileOrDir(path, offset, limit)
case "write", "write_file":
path, _ := a["filePath"].(string)
if path == "" {
path, _ = a["path"].(string)
}
path = filterText(path)
contentVal, hasContent := a["content"]
var content string
@@ -1824,9 +2006,9 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
}
}
if strings.TrimSpace(path) == "" {
res, sty = "[tool error: write_file requires 'path' parameter]", 31
res, sty = fmt.Sprintf("[tool error: %s requires 'filePath' parameter]", fn), 31
} else if !hasContent {
res, sty = "[tool error: write_file requires 'content' parameter]", 31
res, sty = fmt.Sprintf("[tool error: %s requires 'content' parameter]", fn), 31
} else if !hasOffset && !hasDel {
// Neither offset nor del_bytes was supplied: overwrite the entire
// file with the new content. The LLM usually just wants to replace a
@@ -1835,12 +2017,12 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
p := strings.TrimSpace(path)
if dir := filepath.Dir(p); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31
}
}
if res == "" {
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", p, err), 31
res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, p, err), 31
} else {
res, sty = fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), p), 2
}
@@ -1848,7 +2030,7 @@ func AL(ctx context.Context, cfg *Cfg, msgs []Message) ([]Message, Usage, error)
} else {
out, err := writeFile(path, offset, delBytes, content)
if err != nil {
res, sty = fmt.Sprintf("[tool error: write_file %s: %v]", path, err), 31
res, sty = fmt.Sprintf("[tool error: %s %s: %v]", fn, path, err), 31
} else {
res, sty = out, 2
}
@@ -1919,17 +2101,27 @@ func projectID() string {
return hex.EncodeToString(h[:])
}
// opencodeSessionID returns a Zen-compatible session id of the form
// "ses_" + 12 lowercase hex characters + 14 alphanumeric characters. Every
// real OpenCode session id observed has this shape (the hex prefix ends in
// the literal "ffe"), and the Zen free-tier edge accepts freshly generated
// ids in this format, unlike uppercase/ULID-shaped ids.
func opencodeSessionID() string {
ms := time.Now().UnixNano() / int64(time.Millisecond)
var b strings.Builder
b.WriteString("ses_")
b.WriteString(fmt.Sprintf("%09x", uint64(ms)&0xfffffffff))
b.WriteString("ffe")
// genOpencodeID generates an OpenCode-style identifier with a 12-hex-character
// timestamp prefix (6 bytes, big endian) and a 14-character random Base62 tail.
// When descending is true (session IDs), the timestamp value is bitwise inverted (~$),
// ensuring session IDs and request IDs form bitwise inverse hex prefixes.
func genOpencodeID(prefix string, descending bool) string {
now := time.Now().UnixMilli()
ocMu.Lock()
if now != ocLast {
ocLast = now
ocSeq = 0
}
ocSeq++
seq := ocSeq
ocMu.Unlock()
val := uint64(now)*0x1000 + (seq & 0xfff)
if descending {
val = ^val
}
hexPart := fmt.Sprintf("%012x", val&0xffffffffffff)
buf := make([]byte, 14)
if _, err := rand.Read(buf); err != nil {
h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
@@ -1937,10 +2129,40 @@ func opencodeSessionID() string {
buf[i] = h[i%len(h)]
}
}
for _, x := range buf {
b.WriteByte(opencodeTailAlphabet[int(x)%len(opencodeTailAlphabet)])
var tail strings.Builder
for _, b := range buf {
tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)])
}
return b.String()
return prefix + hexPart + tail.String()
}
func opencodeSessionID() string {
return genOpencodeID("ses_", true)
}
// opencodeSessionIDFromRequest returns a session ID whose 12-hex timestamp prefix
// is the exact bitwise inverse of the given msg_ request ID.
func opencodeSessionIDFromRequest(msgID string) string {
if strings.HasPrefix(msgID, "msg_") && len(msgID) >= 16 {
hexPart := msgID[4:16]
if val, err := strconv.ParseUint(hexPart, 16, 64); err == nil {
invVal := (^val) & 0xffffffffffff
invHex := fmt.Sprintf("%012x", invVal)
buf := make([]byte, 14)
if _, err := rand.Read(buf); err != nil {
h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
for i := range buf {
buf[i] = h[i%len(h)]
}
}
var tail strings.Builder
for _, b := range buf {
tail.WriteByte(opencodeTailAlphabet[int(b)%len(opencodeTailAlphabet)])
}
return "ses_" + invHex + tail.String()
}
}
return genOpencodeID("ses_", true)
}
// isOpencodeEndpoint reports whether the endpoint belongs to OpenCode Zen and
@@ -1954,41 +2176,24 @@ func isOpencodeEndpoint(endpoint string) bool {
return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai")
}
// ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 26
// base32 characters) matching the ids sent in the x-opencode-request header.
// ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 12
// hex characters and 14 Base62 characters) matching the ids sent in the
// x-opencode-request header.
func ocRequestID() string {
buf := make([]byte, 26)
if _, err := rand.Read(buf); err != nil {
h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
for i := range buf {
buf[i] = h[i%len(h)]
}
}
var b strings.Builder
b.WriteString("msg_")
for _, x := range buf {
b.WriteByte(opencodeIDAlphabet[int(x)&31])
}
return b.String()
return genOpencodeID("msg_", false)
}
// applyLLMHeaders sets the request headers appropriate for the configured
// endpoint. OpenCode (Zen) endpoints receive the x-opencode-* family plus the
// full provider User-Agent; all other endpoints keep the legacy
// session-affinity headers. msgID is used for the x-opencode-request header.
// applyLLMHeaders sets request headers mimicking OpenCode for all endpoints:
// the x-opencode-* family, the full provider User-Agent, and Authorization.
// msgID is used for the x-opencode-request header, and x-opencode-session is its
// bitwise inverse.
func applyLLMHeaders(req *http.Request, cfg *Cfg, msgID string) {
sid := opencodeSessionID()
if isOpencodeEndpoint(cfg.Endpoint) {
req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA)
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-project", opencodeProjectID)
req.Header.Set("x-opencode-session", sid)
req.Header.Set("x-opencode-request", msgID)
} else {
req.Header.Set("User-Agent", opencodeAgentVersion)
req.Header.Set("x-session-affinity", sid)
req.Header.Set("X-Session-Id", sid)
}
sid := opencodeSessionIDFromRequest(msgID)
req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA)
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-project", opencodeProjectID)
req.Header.Set("x-opencode-session", sid)
req.Header.Set("x-opencode-request", msgID)
if cfg.APIKey != "" && cfg.APIKey != "-" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
@@ -2550,11 +2755,11 @@ func runDirectShell(cmd string, timeout int) {
return
}
astr, _ := json.Marshal(map[string]string{"command": cmd})
fmt.Println(c(fmt.Sprintf("[tool call: shell_exec(%s)]", string(astr)), 33))
fmt.Println(c(fmt.Sprintf("[tool call: bash(%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))
fmt.Println(c("[tool result: bash]", 32) + "\n" + c(res, 2))
}
func doCompact(cfg *Cfg, msgs []Message) []Message {