cd /news/ai-agents/550s-the-wandering-earth-has-550w-yo… · home › topics › ai-agents › article
[ARTICLE · art-139693] src=gist.github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

550S — The Wandering Earth has 550W. Your terminal gets 550 lines of Shell. Powered by agent.sh.

A developer released agent.sh, a single-file AI agent written in pure bash and awk that runs on the shell with only bash, curl and awk as dependencies. The script talks to OpenAI-compatible Chat Completions endpoints, auto-creates an AGENTS.md rules file, a .agents/tools/ directory of executables and a .agents/conv.jsonl conversation history, and includes an inline awk JSON parser for handling tool calls. It is published under the MIT license on GitHub.

by read22 min views18 publishedSep 23, 2026

| | #!/usr/bin/env bash | | | # 550S — Shell. Single-file. Symlink. | | | # | | | # Built on the shell. | | | # Delivered as one file. | | | # Extended with symlinks. | | | # | | | # Source: https://github.com/lloydzhou/agent.sh | | | # License: MIT — see LICENSE in the source repository. | | | # agent.sh — filesystem-first AI agent in pure bash/awk | | | # Chat Completions only; dependencies: bash, curl, awk. | | | # Auto-create: AGENTS.md (rules), .agents/tools/ (executables), .agents/conv.jsonl (history). | | | | | | set -uo pipefail | | | |

|  | MODEL="${MODEL:-gpt-5.6-luna}" | 
|  | API_KEY="${OPENAI_API_KEY:-}" | 
|  | API_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}/chat/completions" | 
|  | AGENT_DIR="${AGENT_DIR:-$PWD/.agents}" | 

| | TOOLS_DIR="$AGENT_DIR/tools" | | | CONV_FILE="$AGENT_DIR/conv.jsonl" |

|  | MAX_TURNS="${MAX_TURNS:-50}" | 
|  | TOOL_TIMEOUT_SECS="${TOOL_TIMEOUT_SECS:-60}" | 
|  | TOOL_RESULT_MAX_BYTES="${TOOL_RESULT_MAX_BYTES:-100000}" | 

| | USER_INPUT="" | | | | | | TOOL_DEFS_JSON="" | | | DISPLAY_LAST_CHAR=$'\n' | | | PREV_WAS_REASONING=false | | | INTERRUPT_REQUESTED=false | | | | | | # ================= inline awk program ================= | | | # Quoted heredoc preserves backslashes; read returns nonzero at EOF. | | | IFS= read -r -d '' AWK_PROGRAM <<'AWK_PROGRAM' || true | | | # Well-formed JSON: direct fields, raw array elements, decoded strings. | | | | | | # Read one JSON value, honoring strings and nested containers. | | | function jread_at(s, i, start, c, depth, quoted, escaped) { | | | while (substr(s, i, 1) ~ /[ \t\r\n]/) i++ | | | start = i |

|  | for (; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if (quoted) { | 
|  | if (escaped) escaped = 0 | 
|  | else if (c == "\\") escaped = 1 | 
|  | else if (c == "\"") { | 

| | quoted = 0 | | | if (!depth) return substr(s, start, i - start + 1) | | | } |

|  | } else { | 
|  | if (!depth && c ~ /[,}\] \t\r\n]/) break | 
|  | if (c == "\"") quoted = 1 | 
|  | else if (c == "{" \|\| c == "[") depth++ | 
|  | else if (c == "}" \|\| c == "]") { | 
|  | if (--depth == 0) return substr(s, start, i - start + 1) | 

| | } | | | } | | | } | | | return substr(s, start, i - start) | | | } | | | |

|  | function jfield(s, key,    i, k, v) { | 
|  | sub(/^[ \t\r\n]+/, "", s) | 
|  | if (substr(s, 1, 1) != "{") return "" | 
|  | for (i = 2; i < length(s);) { | 
|  | while (substr(s, i, 1) ~ /[, \t\r\n]/) i++ | 
|  | k = jread_at(s, i); i += length(k) | 
|  | while (substr(s, i, 1) ~ /[: \t\r\n]/) i++ | 
|  | v = jread_at(s, i) | 
|  | if (k == "\"" key "\"") return v | 
|  | if (v == "") break | 
|  | i += length(v) | 

| | } | | | return "" | | | } | | | |

|  | function jstr(s, key,    v) { | 
|  | v = jfield(s, key) | 
|  | return substr(v, 1, 1) == "\"" ? junesc(substr(v, 2, length(v) - 2)) : "" | 

| | } | | | |

|  | function jsplit(s, out,    i, v, n) { | 
|  | split("", out) | 
|  | sub(/^[ \t\r\n]+/, "", s) | 
|  | if (substr(s, 1, 1) != "[") return 0 | 
|  | for (i = 2; i < length(s);) { | 
|  | while (substr(s, i, 1) ~ /[, \t\r\n]/) i++ | 
|  | v = jread_at(s, i) | 
|  | if (v == "") break | 
|  | out[++n] = v; i += length(v) | 

| | } | | | return n + 0 | | | } | | | |

|  | # ---------- escape / unescape ---------- | 
|  | function jescape(s,    out, i, c, code) { | 
|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if ((code = index("\"\\\b\f\n\r\t", c)) > 0) | 
|  | out = out "\\" substr("\"\\bfnrt", code, 1) | 

| | else if ((code = index("\001\002\003\004\005\006\007\010\011\012\013\014\015\016\017\020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037", c)) > 0) | | | out = out sprintf("\u%04x", code) | | | else out = out c | | | } | | | return out | | | } | | | | | | function junesc(s, out, i, c, pos, hex, cp, lo) { |

|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | if (c != "\\") { out = out c; continue } | 
|  | if (++i > length(s)) break | 
|  | c = substr(s, i, 1) | 
|  | if (c != "u") { | 
|  | pos = index("bfnrt\"\\/", c) | 
|  | out = out (pos ? substr("\b\f\n\r\t\"\\/", pos, 1) : c) | 

| | continue | | | } |

|  | hex = substr(s, i + 1, 4) | 
|  | cp = hex_to_int(hex) | 
|  | if (length(hex) < 4 \|\| cp < 0) { out = out "\\u"; continue } | 
|  | if (cp >= 55296 && cp <= 56319 && i + 10 <= length(s) && substr(s, i + 5, 2) == "\\u") { | 
|  | lo = hex_to_int(substr(s, i + 7, 4)) | 
|  | if (lo >= 56320 && lo <= 57343) { | 
|  | out = out utf8(65536 + (cp - 55296) * 1024 + lo - 56320) | 
|  | i += 10; continue | 

| | } | | | } | | | out = out (cp >= 55296 && cp <= 57343 ? "\u" hex : utf8(cp)) | | | i += 4 | | | } | | | return out | | | } | | | |

|  | function hex_to_int(hex,    i, d, value) { | 
|  | for (i = 1; i <= length(hex); i++) { | 
|  | d = index("0123456789abcdef", tolower(substr(hex, i, 1))) - 1 | 
|  | if (d < 0) return -1 | 

| | value = value * 16 + d | | | } | | | return value + 0 | | | } | | | | | | function utf8(cp) { | | | # Decoding runs under LC_ALL=C; build UTF-8 bytes explicitly. |

|  | if (cp <= 127) return sprintf("%c", cp) | 
|  | if (cp <= 2047) return sprintf("%c%c", 192 + int(cp / 64), 128 + (cp % 64)) | 
|  | if (cp <= 65535) return sprintf("%c%c%c", 224 + int(cp / 4096), 128 + int((cp % 4096) / 64), 128 + (cp % 64)) | 
|  | return sprintf("%c%c%c%c", 240 + int(cp / 262144), 128 + int((cp % 262144) / 4096), 128 + int((cp % 4096) / 64), 128 + (cp % 64)) | 

| | } | | | | | | # ---------- CLI ---------- | | | BEGIN { |

|  | if (json_mode == "escape_string") { | 
|  | if ((getline json_input) < 0) json_input = "" | 
|  | else while ((getline _line) > 0) json_input = json_input "\n" _line | 
|  | printf "%s", jescape(substr(json_input, 1, length(json_input) - 1)) | 

| | exit 0 | | | } | | | } | | | # SSE -> TSV; tool_calls: name, id, call JSON, argv prefixed with '=' to preserve empty args. | | | |

|  | function esc(s,    out, i, c) { | 
|  | for (i = 1; i <= length(s); i++) { | 
|  | c = substr(s, i, 1) | 
|  | out = out (c == "\\" ? "\\\\" : c == "\t" ? "\\t" : c == "\n" ? "\\n" : c == "\r" ? "\\r" : c) | 

| | } | | | return out | | | } | | | | | | BEGIN { _reset() } | | | |

|  | /^error\t/ { print "error\t" esc(substr($0, 7)); fflush(); done = 1; exit 0 } | 
|  | /^retry$/ { _reset(); print "retry"; fflush(); next } | 
|  | /^data:  $$ DONE $$ / { | 
|  | _emit_tools() | 

| | if (pending_stop != "") print "finish_reason\t" pending_stop | | | fflush(); done = 1; next | | | } |

|  | /^data: / { | 
|  | jsplit(jfield(substr($0, 7), "choices"), choices) | 
|  | choice = choices[1] | 
|  | delta = jfield(choice, "delta") | 
|  | fr = jstr(choice, "finish_reason") | 
|  | if (fr != "") pending_stop = fr | 
|  | content = jstr(delta, "content") | 
|  | if (content != "") print "content\t" esc(content) | 
|  | reasoning = jstr(delta, "reasoning_content") | 
|  | if (reasoning != "") print "reasoning\t" esc(reasoning) | 
|  | _parse_tool_calls(jfield(delta, "tool_calls")) | 
|  | if (fr == "tool_calls") _emit_tools() | 
|  | fflush() | 

| | } | | | END { | | | if (json_mode != "escape_string" && !done && pending_stop == "") print "error\tStream interrupted (no [DONE] received)" | | | } | | | |

|  | function _reset() { | 
|  | done = 0; pending_stop = ""; tc_max = -1 | 
|  | split("", tc_name); split("", tc_id); split("", tc_args) | 

| | } | | | | | | function _parse_tool_calls(json, n, p, tc, idx, f, id) { |

|  | n = jsplit(json, chunks) | 
|  | for (p = 1; p <= n; p++) { | 
|  | tc = chunks[p] | 
|  | idx = jfield(tc, "index") | 
|  | if (idx !~ /^[0-9]+$/) continue | 

| | idx += 0 |

|  | if (idx > tc_max) tc_max = idx | 
|  | f = jfield(tc, "function") | 
|  | id = jstr(tc, "id") | 
|  | if (id != "") tc_id[idx] = id | 
|  | tc_name[idx] = tc_name[idx] jstr(f, "name") | 
|  | tc_args[idx] = tc_args[idx] jstr(f, "arguments") | 

| | } | | | } | | | |

|  | function _emit_tools(    idx, n, i, call, line) { | 
|  | for (idx = 0; idx <= tc_max; idx++) { | 
|  | if (tc_args[idx] == "") continue | 

| | # Preserve the arguments JSON text; decode only the execution copy. |

|  | call = "{\"id\":\"" jescape(tc_id[idx]) "\",\"type\":\"function\",\"function\":{\"name\":\"" jescape(tc_name[idx]) "\",\"arguments\":\"" jescape(tc_args[idx]) "\"}}" | 
|  | line = "tool_calls\t" esc(tc_name[idx]) "\t" esc(tc_id[idx]) "\t" esc(call) | 
|  | n = jsplit(jfield(tc_args[idx], "args"), argv) | 
|  | for (i = 1; i <= n; i++) line = line "\t=" esc(junesc(substr(argv[i], 2, length(argv[i]) - 2))) | 

| | print line | | | tc_args[idx] = "" | | | } | | | fflush() | | | } | | | AWK_PROGRAM | | | |

|  | # ================= utils ================= | 
|  | util_awk_run() { LC_ALL=C LANG=C awk "$@"; } | 

| | | | | util_unescape() { | | | # Decode line-protocol fields into REPLY without stripping trailing newlines. | | | local s="$1" c | | | REPLY="" |

|  | while [[ "$s" == *\\* ]]; do | 
|  | REPLY+="${s%%\\*}"; s="${s#*\\}" | 
|  | c="${s:0:1}"; s="${s:1}" | 
|  | case "$c" in n) REPLY+=$'\n' ;; t) REPLY+=$'\t' ;; r) REPLY+=$'\r' ;; *) REPLY+="$c" ;; esac | 

| | done | | | REPLY+="$s" | | | } | | | | | | util_run_timeout() { | | | local seconds="$1" pid watcher rc; shift |

|  | "$@" <&0 2>&1 & pid=$! | 
|  | ( sleep "$seconds"; kill -TERM "$pid" 2>/dev/null ) >/dev/null 2>&1 & watcher=$! | 
|  | wait "$pid" 2>/dev/null; rc=$? | 

| | kill "$watcher" 2>/dev/null | | | return $(( rc == 143 ? 124 : rc )) | | | } | | | |

|  | util_die() { | 
|  | printf '\033[31mError: %s\033[0m\n' "$*" >&2 | 

| | exit 1 | | | } | | | | | | util_json_escape() { | | | # A final sentinel preserves trailing newlines through awk's record reader. | | | printf '%s.' "${1:-}" | util_awk_run -v json_mode=escape_string "$AWK_PROGRAM" | | | } | | | |

|  | # ================= system prompt ================= | 
|  | agent_build_prompt() { | 

| | cat <<'PROMPT' | | | You are agent.sh, a minimal filesystem-first agent running in a terminal. | | | | | | Tools: | | | - Tools are plain executables (binaries, symlinks, scripts) in the agent tools directory; the tool name is the file name. | | | - Each call passes "args": an array of strings. Elements are handed to the command as separate arguments — like Docker CMD exec form. No shell expansion: quotes, spaces, and $ are literal. Pass [] when the tool takes no arguments. | | | - Read each tool's description before calling it. | | | - A tool returns stdout and stderr. Non-zero exit is prefixed "Error (exit N)" — read the message and self-correct on the next call. Long output is truncated; use head/tail/grep to narrow large reads. | | | - Shell composition (pipes, $(...), globs) does not apply to args. If a "bash" tool exists, call it with ["-c", "<command line>"] for full shell features. | | | - You may issue several independent tool calls in one turn. | | | PROMPT |

|  | local locale="${LC_ALL:-${LC_MESSAGES:-${LANG:-en_US}}}" lang_name skill name desc indexed="" | 
|  | locale="${locale%%.*}" | 
|  | case "$locale" in ""\|C\|C.*\|POSIX) locale="en_US" ;; esac | 
|  | case "$locale" in zh*) lang_name="Chinese" ;; *) lang_name="English" ;; esac | 
|  | printf 'Environment:\nlang: %s\npwd: %s\nhome: %s\nplatform: %s\nshell: %s\n' \ | 
|  | "$locale" "$PWD" "${HOME:-?}" "$(uname -s 2>/dev/null \|\| echo unknown)" "${SHELL:-unknown}" | 

| | printf '\nBy default, use %s (%s) for output, including reasoning/thinking, unless user instructions specify otherwise. Code, commands, and file content remain as-is.\n' "$lang_name" "$locale" | | | for skill in "$AGENT_DIR"/skills/*/SKILL.md; do |

|  | [[ -f "$skill" ]] \|\| continue | 
|  | if [[ -z "$indexed" ]]; then | 

| | printf '\n<skill-index>\nWhen a skill matches the task, read its SKILL.md using any suitable available tool (e.g. cat or bash); no dedicated skill tool is needed. If none is available, do not assume its contents. Resolve relative paths from the skill directory.\n' | | | indexed=1 | | | fi |

|  | name="${skill%/SKILL.md}"; name="${name##*/}" | 
|  | desc=$(awk ' | 
|  | { sub(/\r$/, "") } | 
|  | NR == 1 { if ($0 != "---") exit; next } | 
|  | /^---$/ { exit } | 
|  | /^description:[ \t]*/ { sub(/^description:[ \t]*/, ""); sub(/[ \t]+$/, ""); if ($0 ~ /^[\|>][-+0-9]*$/) exit; if ($0 ~ /^".*"$/ \|\| $0 ~ /^\047.*\047$/) $0 = substr($0, 2, length($0) - 2); print; exit } | 

| | ' "$skill") | | | printf -- '- %s%s\n path: %s\n' "$name" "${desc:+: $desc}" "$skill" | | | done |

|  | [[ -n "$indexed" ]] && printf '</skill-index>\n' | 
|  | if [[ -f "$PWD/AGENTS.md" ]]; then | 

| | printf '\nUser instructions:\n' | | | cat "$PWD/AGENTS.md" | | | fi | | | } | | | |

|  | # ================= tools (filesystem-discovered command templates) ================= | 
|  | tools_load() { | 

| | TOOL_DEFS_JSON="" | | | [[ -d "$TOOLS_DIR" ]] || return 0 | | | local f name desc | | | for f in "$TOOLS_DIR"/*; do |

|  | [[ -x "$f" ]] \|\| continue # tools are executables: binaries, symlinks, scripts | 
|  | name="${f##*/}" | 
|  | [[ "$name" =~ ^[A-Za-z0-9_-]{1,64}$ ]] \|\| { printf 'skip tool (bad name): %s\n' "$name" >&2; continue; } | 
|  | desc=$(tools_auto_desc "$f") | 
|  | [[ -n "$desc" ]] \|\| desc="Run $name" | 

| | TOOL_DEFS_JSON+="${TOOL_DEFS_JSON:+,}"'{"type":"function","function":{"name":"'"$name"'","description":"'"$(util_json_escape "$desc")"'","parameters":{"type":"object","properties":{"args":{"type":"array","items":{"type":"string"},"description":"command-line arguments for '"$name"', passed as separate arguments (exec form); [] if none"}}}}}' | | | done | | | } | | | |

|  | tools_auto_desc() { | 
|  | # Exact-name man match; fall back to --help with stdin=/dev/null and a timeout. | 
|  | local name="${1##*/}" d | 
|  | d=$(man -f "$name" 2>/dev/null \| awk -v name="$name" '$0 ~ "^" name "\$[0-9]+\$" { sub(/^[^(]*$[^)]*$[[:space:]]*-[[:space:]]*/, ""); print; exit }') | 
|  | [[ -z "$d" ]] && d=$(util_run_timeout 3 "$1" --help </dev/null 2>&1 \| grep -m1 -v '^[[:space:]]*$') | 

| | printf '%s' "$d" | | | } | | | |

|  | tool_execute() { | 
|  | local name="$1" f="$TOOLS_DIR/$1" output rc; shift | 
|  | [[ -x "$f" ]] \|\| { printf 'Error: no such tool: %s' "$name"; return 0; } | 
|  | output=$(util_run_timeout "$TOOL_TIMEOUT_SECS" bash -c 'exec "$0" "$@"' "$f" "$@" 2>&1) | 

| | rc=$? |

|  | (( rc != 0 )) && output="Error (exit $rc): $output" | 
|  | if (( ${#output} > TOOL_RESULT_MAX_BYTES )); then | 
|  | local head=$(( (TOOL_RESULT_MAX_BYTES + 1) / 2 )) tail=$(( TOOL_RESULT_MAX_BYTES / 2 )) | 
|  | output="${output:0:head}"$'\n...[truncated]...\n'"${output: ${#output}-tail}" | 

| | fi | | | printf '%s' "$output" | | | } | | | |

|  | # ================= conversation (conv.jsonl: one OpenAI message per line) ================= | 
|  | conv_append() { printf '%s\n' "$1" >> "$CONV_FILE"; } | 

| | |

|  | # ================= llm ================= | 
|  | llm_call() { | 
|  | local body='{"model":"'"$MODEL"'","messages":[{"role":"system","content":"'"$(util_json_escape "$(agent_build_prompt)")"'"}' | 
|  | body+="$(awk '{printf ",%s", $0}' "$CONV_FILE")"']' | 
|  | [[ -n "$TOOL_DEFS_JSON" ]] && body+=',"tools":['"$TOOL_DEFS_JSON"']' | 
|  | body+=',"stream":true}' | 
|  | curl -sS --no-buffer -D - --retry 2 --retry-delay 1 --retry-max-time 20 \ | 
|  | --connect-timeout 5 --speed-limit 1 --speed-time 60 \ | 
|  | -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ | 
|  | -d "$body" "$API_URL" 2>&1 \ | 

| | | util_awk_run ' |

|  | { sub(/\r$/, "") } | 
|  | /^curl: / { print "error\t" $0; fflush(); exit 1 } | 

| | /^HTTP// { |

|  | if (body) { print "retry"; fflush() } | 
|  | code = $2; body = 0; err = ""; next | 

| | } |

|  | /^[ \t]*$/ && !body { body = 1; next } | 
|  | !body { next } | 
|  | code >= 400 { err = err (err == "" ? "" : "\n") $0; next } | 
|  | { print; fflush() } | 

| | END { |

|  | if (code >= 400) { | 
|  | print "error\tHTTP " code ": " (err == "" ? "(empty)" : err) | 
|  | fflush() | 

| | } | | | } | | | ' \ | | | | util_awk_run "$AWK_PROGRAM" | | | } | | | |

|  | # ================= display ================= | 
|  | display_ensure_newline() { | 
|  | if [[ "$DISPLAY_LAST_CHAR" != $'\n' ]]; then printf '\n'; DISPLAY_LAST_CHAR=$'\n'; fi | 

| | return 0 | | | } | | | |

|  | display() { | 
|  | local kind="$1"; shift | 

| | case "$kind" in | | | content|reasoning) |

|  | [[ -n "$1" ]] \|\| return 0 | 
|  | if [[ "$kind" == reasoning ]]; then | 

| | PREV_WAS_REASONING=true | | | printf '\033[90m%s\033[0m' "$1" | | | else | | | [[ "$PREV_WAS_REASONING" == true ]] && printf '\n' | | | PREV_WAS_REASONING=false | | | printf '%s' "$1" | | | fi |

|  | DISPLAY_LAST_CHAR="${1: -1}"; return 0 ;; | 
|  | tool) display_ensure_newline; printf '\033[33m[tool] %s %s\033[0m\n' "$1" "${2:0:120}" ;; | 

| | result) |

|  | local first="${2%%$'\n'*}" | 
|  | printf '\033[90m[%s done] %s\033[0m\n' "$1" "${first:0:120}" ;; | 
|  | error) display_ensure_newline; printf '\033[31mError: %s\033[0m\n' "$1" >&2 ;; | 
|  | retry) printf '\033[90mretrying...\033[0m\n' >&2 ;; | 

| | esac | | | DISPLAY_LAST_CHAR=$'\n' | | | } | | | |

|  | # ================= agent loop ================= | 
|  | agent_loop() { | 
|  | local turn=0 stop="" text="" reasoning="" reasoning_field="" calls="" tool_messages="" _line _type _rest | 
|  | conv_append '{"role":"user","content":"'"$(util_json_escape "$1")"'"}' | 

| | INTERRUPT_REQUESTED=false | | | trap 'INTERRUPT_REQUESTED=true' INT |

|  | while (( turn < MAX_TURNS )); do | 
|  | (( turn++ )) \|\| true | 
|  | stop=""; text=""; reasoning=""; calls=""; tool_messages="" | 
|  | exec 8< <(llm_call) | 
|  | while IFS= read -r _line <&8; do | 
|  | [[ "$INTERRUPT_REQUESTED" == true ]] && { stop="interrupted"; break; } | 
|  | _type="${_line%%$'\t'*}" | 
|  | _rest="${_line#*$'\t'}" | 

| | case "$_type" in | | | content) | | | util_unescape "$_rest" | | | display content "$REPLY" | | | text+="$REPLY" ;; | | | reasoning) | | | util_unescape "$_rest"; display reasoning "$REPLY"; reasoning+="$REPLY" ;; | | | tool_calls) | | | # name, id, complete call JSON, then '='-prefixed argv fields. |

|  | local _f=() _argv=() _name _id _call _output _i | 
|  | IFS=$'\t' read -r -a _f <<<"$_rest" | 
|  | util_unescape "${_f[0]}"; _name="$REPLY" | 
|  | util_unescape "${_f[1]}"; _id="$REPLY" | 
|  | util_unescape "${_f[2]}"; _call="$REPLY" | 
|  | for _i in ${_f[@]+"${_f[@]:3}"}; do | 
|  | util_unescape "${_i:1}"; _argv+=("$REPLY") | 

| | done |

|  | display tool "$_name" "${_argv[*]-}" | 
|  | _output=$(tool_execute "$_name" ${_argv[@]+"${_argv[@]}"}) | 

| | display result "$_name" "$_output" |

|  | calls+="${calls:+,}$_call" | 
|  | tool_messages+='{"role":"tool","tool_call_id":"'"$(util_json_escape "$_id")"'","content":"'"$(util_json_escape "$_output")"'"}'$'\n' ;; | 

| | retry) | | | display retry | | | text=""; reasoning=""; calls=""; tool_messages="" ;; | | | finish_reason) | | | stop="$_rest" ;; | | | error) | | | util_unescape "$_rest"; display error "$REPLY" | | | stop="error"; break ;; | | | esac | | | done | | | exec 8<&- | | | [[ "$stop" == "interrupted" || "$stop" == "error" ]] && break | | | # Persist this assistant turn, then continue only when tools were called | | | reasoning_field="" |

|  | [[ -n "$reasoning" ]] && reasoning_field=',"reasoning_content":"'"$(util_json_escape "$reasoning")"'"' | 
|  | if [[ -n "$calls" ]]; then | 

| | local content_field="null" |

|  | [[ -n "$text" ]] && content_field='"'"$(util_json_escape "$text")"'"' | 
|  | conv_append '{"role":"assistant","content":'"$content_field"',"tool_calls":['"$calls"']'"$reasoning_field"'}' | 

| | printf '%s' "$tool_messages" >> "$CONV_FILE" | | | [[ "$stop" == "tool_calls" ]] && continue | | | else | | | [[ -n "$text$reasoning" ]] && conv_append '{"role":"assistant","content":"'"$(util_json_escape "$text")"'"'"$reasoning_field"'}' | | | fi | | | break | | | done | | | trap - INT | | | if (( turn >= MAX_TURNS )); then | | | display error "Max turns ($MAX_TURNS) reached" | | | return 1 | | | fi | | | [[ "$stop" == error ]] && return 1 | | | return 0 | | | } | | | |

|  | # ================= interactive ================= | 
|  | interactive_mode() { | 

| | local line | | | printf '\033[36magent.sh (%s) — type exit or Ctrl+D to quit\033[0m\n' "$MODEL" | | | while true; do |

|  | if ! IFS= read -e -r -p $'\033[32m550S>\033[0m ' line; then printf '\n'; break; fi | 
|  | [[ "$line" == "exit" \|\| "$line" == "quit" ]] && break | 
|  | [[ -z "$line" ]] && continue | 

| | history -s "$line" 2>/dev/null || true | | | agent_loop "$line" | | | display_ensure_newline | | | done | | | printf '\033[36mGoodbye!\033[0m\n' | | | } | | | |

|  | # ================= cli ================= | 
|  | usage() { | 

| | cat <<'EOF' | | | agent.sh — filesystem-first AI agent (OpenAI Chat Completions) | | | | | | Usage: | | | agent.sh [prompt] chat; no prompt + tty opens interactive REPL | | | agent.sh < file read prompt from stdin | | | fresh conversation: mv -i .agents/conv.jsonl ".agents/conv-$(date +%Y%m%d-%H%M%S).jsonl" | | | | | | Missing AGENTS.md, tools directory, and history file are created on startup. | | | | | | Options: |

|  | -m, --model NAME   model override (env MODEL) | 
|  | -h, --help         this help | 

| | | | | Environment: | | | OPENAI_API_KEY required | | | OPENAI_BASE_URL default https://api.openai.com/v1 | | | MODEL default gpt-5.6-luna | | | EOF | | | } | | | |

|  | parse_args() { | 
|  | while (( $# > 0 )); do | 

| | case "$1" in |

|  | -m\|--model) [[ -n "${2:-}" ]] \|\| util_die "$1 requires a model"; MODEL="$2"; shift 2 ;; | 
|  | -h\|--help) usage; exit 0 ;; | 
|  | -*) util_die "unknown option: $1 (see --help)" ;; | 
|  | *) [[ -n "$USER_INPUT" ]] && util_die "unexpected extra argument: $1" | 
|  | USER_INPUT="$1"; shift ;; | 

| | esac | | | done | | | return 0 | | | } | | | | | | main() { | | | parse_args "$@" | | | [[ -n "$API_KEY" ]] || util_die "OPENAI_API_KEY is not set" | | | mkdir -p "$TOOLS_DIR" && touch "$CONV_FILE" || util_die "Cannot initialize $AGENT_DIR" | | | if [[ ! -e "$PWD/AGENTS.md" ]]; then | | | printf '# Agent instructions\n\n' > "$PWD/AGENTS.md" || util_die "Cannot create AGENTS.md" | | | fi | | | tools_load |

|  | if [[ -z "$USER_INPUT" ]]; then | 
|  | if [[ -t 0 ]]; then interactive_mode; return $?; fi | 
|  | USER_INPUT=$(cat) | 

| | fi | | | agent_loop "$USER_INPUT" | | | local rc=$? | | | display_ensure_newline | | | return "$rc" | | | } | | | | | | main "$@" |

── more in #ai-agents 4 stories · sorted by recency
── more on @github 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/550s-the-wandering-e…] indexed:0 read:22min 2026-09-23 · —