1281 lines
48 KiB
Bash
Executable File
1281 lines
48 KiB
Bash
Executable File
#!/bin/sh
|
|
# ai.sh — aish: AI shell agent.
|
|
#
|
|
# An AI agent with shell access. By default, context (log, config, prompt)
|
|
# is stored globally in ~/.aish/. Use --init to create a local .aish/ in
|
|
# the current directory for a customized, self-contained agent.
|
|
#
|
|
# Usage:
|
|
# ./ai.sh what is the capital of Japan
|
|
# One-shot: process message, save context, exit
|
|
# (multi-word messages need no quotes)
|
|
# ./ai.sh -a "goal" Autonomous: run until done or budget exhausted
|
|
# ./ai.sh -c find python files Command mode: LLM generates a shell command
|
|
# from the description, prints it, and runs it
|
|
# ./ai.sh -i Initialize a local .aish/ in the current dir
|
|
#
|
|
# Dependencies: curl, jq, coreutils (date, mkdir, chmod, head, cat, printf,
|
|
# mktemp, timeout, sync, basename, sleep, wc, tail, sed, grep).
|
|
# No build step.
|
|
|
|
# NOTE: intentionally NOT using `set -e`. This script has many intentional
|
|
# non-zero return paths (budget_check, api_call, agent_loop_run, the
|
|
# `[ test ] && action` override pattern in config_load). `set -e` would
|
|
# abort on those. Instead, errors are handled explicitly at every critical
|
|
# point: curl exit codes, jq parse fallbacks, api_call/budget_check return
|
|
# values checked in `if` context. This mirrors how the C code checks
|
|
# return values rather than relying on a global trap.
|
|
|
|
# =====================================================================
|
|
# Defaults (src/defaults.h)
|
|
# =====================================================================
|
|
# These are the built-in fallbacks. Any of these can be overridden by
|
|
# ~/.aish.conf (sourced below if present), ~/.aish/config.json (global),
|
|
# or .aish/config.json (local, if .aish/ exists in the current directory).
|
|
|
|
# DEFAULT_MODEL="glm-5.2"
|
|
DEFAULT_MODEL="gpt-5.4-mini"
|
|
# DEFAULT_MODEL="~google/gemini-flash-latest"
|
|
DEFAULT_API_BASE="https://api.ppq.ai"
|
|
DEFAULT_API_KEY=""
|
|
DEFAULT_MAX_COST="10.0" # USD
|
|
DEFAULT_MAX_STEPS="500"
|
|
DEFAULT_TIMEOUT="7200" # seconds, overall
|
|
DEFAULT_CMD_TIMEOUT="60" # seconds, per shell command
|
|
DEFAULT_MAX_OUTPUT_TOKENS="65536"
|
|
DEFAULT_TEMPERATURE="0.7"
|
|
|
|
# Load global user config if present (overrides defaults above).
|
|
# Create ~/.aish.conf with: DEFAULT_API_KEY="sk-..."
|
|
# See README.md for details.
|
|
[ -f "$HOME/.aish.conf" ] && . "$HOME/.aish.conf"
|
|
|
|
# Buffer limits (src/defaults.h:36)
|
|
MAX_STDOUT_CHARS=8000
|
|
MAX_STDERR_CHARS=4000
|
|
|
|
# API constants (src/defaults.h:54)
|
|
API_TIMEOUT_SECONDS=120
|
|
API_MAX_RETRIES=3
|
|
API_BACKOFF_BASE_SEC=5 # 5s, 10s, 15s
|
|
|
|
# Paths
|
|
AISH_DIR=".aish"
|
|
LOG_FILENAME="log.jsonl"
|
|
CONFIG_FILENAME="config.json"
|
|
PROMPT_FILENAME="system_prompt.txt"
|
|
SOCK_FILENAME="sock"
|
|
|
|
# Default system prompt (src/main.c:33 / prompts/default_system_prompt.txt)
|
|
DEFAULT_SYSTEM_PROMPT='You are an AI agent with shell access. You can run any command on this
|
|
system using the run_shell tool. You are operating in the current working
|
|
directory.
|
|
|
|
When given a task:
|
|
1. Reason about what needs to be done.
|
|
2. Use run_shell to explore the environment, read files, run commands.
|
|
3. Take action to complete the task.
|
|
4. When finished, put your final answer in your text response, then call
|
|
done() with a brief summary.
|
|
|
|
Your text response is what the user sees. Always include the complete answer
|
|
in your text response before calling done(). Do not leave the answer only in
|
|
command output — summarize the results in your own words.
|
|
|
|
Be concise in your reasoning. Use shell commands efficiently. If a command
|
|
fails, examine the error and try a different approach.
|
|
|
|
You have full access to the system. Be careful with destructive commands.'
|
|
|
|
# Tool descriptions (src/defaults.h:89)
|
|
TOOL_RUN_SHELL_DESC='Run a shell command on this system. Returns stdout, stderr, and exit code. Use this to run any program, read/write files, make network requests, use git, etc.'
|
|
TOOL_DONE_DESC='Signal that you have completed the task. Call this only when you are truly finished with the current request.'
|
|
|
|
# =====================================================================
|
|
# Paths (src/config.c:32)
|
|
# =====================================================================
|
|
|
|
CWD="$(pwd)"
|
|
LOCAL_DIR="$CWD/$AISH_DIR"
|
|
GLOBAL_DIR="$HOME/.aish"
|
|
|
|
# Local mode: .aish/ exists in the current directory.
|
|
# Global mode (default): use ~/.aish/ for all storage.
|
|
if [ -d "$LOCAL_DIR" ]; then
|
|
AISH_MODE="local"
|
|
DIR="$LOCAL_DIR"
|
|
else
|
|
AISH_MODE="global"
|
|
DIR="$GLOBAL_DIR"
|
|
fi
|
|
|
|
LOG_PATH="$DIR/$LOG_FILENAME"
|
|
CONFIG_PATH="$DIR/$CONFIG_FILENAME"
|
|
PROMPT_PATH="$DIR/$PROMPT_FILENAME"
|
|
SOCK_PATH="$DIR/$SOCK_FILENAME"
|
|
|
|
# =====================================================================
|
|
# Globals: config + runtime state
|
|
# =====================================================================
|
|
|
|
# Config fields (populated by config_load)
|
|
CFG_MODEL=""
|
|
CFG_API_BASE=""
|
|
CFG_MAX_COST=""
|
|
CFG_MAX_STEPS=""
|
|
CFG_TIMEOUT=""
|
|
CFG_CMD_TIMEOUT=""
|
|
CFG_MAX_OUTPUT_TOKENS=""
|
|
CFG_TEMPERATURE=""
|
|
|
|
# Runtime
|
|
MODE="standalone" # standalone | autonomous
|
|
MESSAGE=""
|
|
RESUME=1
|
|
API_KEY=""
|
|
|
|
# Budget (src/budget.c)
|
|
BUDGET_STEP=0
|
|
BUDGET_COST=0.0
|
|
BUDGET_START=0
|
|
|
|
# Conversation: a temp file holding one JSON message object per line.
|
|
# Assembled into an array with jq -s '.' when building the API request.
|
|
MESSAGES_FILE=""
|
|
|
|
# =====================================================================
|
|
# Usage (src/main.c:50)
|
|
# =====================================================================
|
|
|
|
usage() {
|
|
prog="$1"
|
|
cat >&2 <<EOF
|
|
Usage: $prog [OPTIONS] [MESSAGE]
|
|
|
|
An AI agent with shell access. Multi-word messages do not need quotes.
|
|
By default, context is stored globally in ~/.aish/. Use --init to create
|
|
a local .aish/ in the current directory for a self-contained agent.
|
|
|
|
Modes:
|
|
$prog what is the capital of Japan
|
|
One-shot: process message, save context, exit
|
|
$prog -a "goal" Autonomous: run until done or budget exhausted
|
|
$prog -c find python files modified this week
|
|
Command mode: LLM generates a shell command from
|
|
the description, prints it, and runs it
|
|
$prog -i Initialize a local .aish/ in the current directory
|
|
|
|
Options:
|
|
-c, --cmd Command mode (generate and run a shell command)
|
|
-a, --autonomous Run in autonomous mode (goal required)
|
|
-i, --init Create local .aish/ with default config + prompt, exit
|
|
-x, --clear Clear the log file and exit
|
|
-r, --reset Clear the log file and exit (alias for -x)
|
|
-t, --tail Tail (follow) the log file in real time
|
|
-v, --verbose Show status lines ([loaded], [step], [finished], etc.)
|
|
Default is quiet: only the response is printed.
|
|
-m, --model MODEL Override model from config
|
|
-k, --api-key KEY API key (or set PPQ_API_KEY env var)
|
|
-C, --max-cost USD Override max cost from config
|
|
-s, --max-steps N Override max steps from config
|
|
--timeout SECS Override overall timeout from config
|
|
-T, --cmd-timeout SECS Override per-command timeout from config
|
|
--resume Resume from existing log (default: yes)
|
|
-R, --no-resume Start fresh (ignore existing log)
|
|
-h, --help Show this help
|
|
|
|
Environment:
|
|
PPQ_API_KEY API key for ppq.ai
|
|
|
|
Storage:
|
|
~/.aish/ Global: config.json, system_prompt.txt, log.jsonl
|
|
.aish/ Local: same files, only if created with --init
|
|
|
|
Config priority: CLI flags > local .aish/config.json (if .aish/ exists)
|
|
> ~/.aish/config.json > ~/.aish.conf shell vars > built-in defaults.
|
|
EOF
|
|
}
|
|
|
|
# =====================================================================
|
|
# Arg parsing defaults (actual parsing happens in main flow below)
|
|
# =====================================================================
|
|
|
|
IS_AUTONOMOUS=0
|
|
CMD_MODE=0
|
|
INIT_MODE=0
|
|
CLEAR_MODE=0
|
|
TAIL_MODE=0
|
|
VERBOSE=0
|
|
MODEL_OVERRIDE=""
|
|
API_KEY_OVERRIDE=""
|
|
HAS_MAX_COST=0
|
|
HAS_MAX_STEPS=0
|
|
HAS_TIMEOUT=0
|
|
HAS_CMD_TIMEOUT=0
|
|
MAX_COST_OVERRIDE=""
|
|
MAX_STEPS_OVERRIDE=""
|
|
TIMEOUT_OVERRIDE=""
|
|
CMD_TIMEOUT_OVERRIDE=""
|
|
|
|
# =====================================================================
|
|
# Ensure data dir exists + default config + default prompt
|
|
# In global mode, creates ~/.aish/ if missing. In local mode, .aish/
|
|
# already exists (we checked at path resolution), so this just ensures
|
|
# the config and prompt files exist.
|
|
# =====================================================================
|
|
|
|
ensure_data_dir() {
|
|
mkdir -p "$DIR"
|
|
chmod 700 "$DIR" 2>/dev/null || true
|
|
|
|
# Default config.json (src/config.c:107)
|
|
if [ ! -f "$CONFIG_PATH" ]; then
|
|
jq -n \
|
|
--arg model "$DEFAULT_MODEL" \
|
|
--arg base "$DEFAULT_API_BASE" \
|
|
--argjson maxcost "$DEFAULT_MAX_COST" \
|
|
--argjson maxsteps "$DEFAULT_MAX_STEPS" \
|
|
--argjson timeout "$DEFAULT_TIMEOUT" \
|
|
--argjson cmdtimeout "$DEFAULT_CMD_TIMEOUT" \
|
|
--arg prompt "$PROMPT_FILENAME" \
|
|
--argjson maxtok "$DEFAULT_MAX_OUTPUT_TOKENS" \
|
|
--argjson temp "$DEFAULT_TEMPERATURE" \
|
|
'{model:$model, api_base:$base, max_cost:$maxcost, max_steps:$maxsteps,
|
|
timeout:$timeout, cmd_timeout:$cmdtimeout, system_prompt:$prompt,
|
|
max_output_tokens:$maxtok, temperature:$temp}' \
|
|
> "$CONFIG_PATH"
|
|
fi
|
|
|
|
# Default system_prompt.txt (src/config.c:122)
|
|
if [ ! -f "$PROMPT_PATH" ]; then
|
|
printf '%s\n' "$DEFAULT_SYSTEM_PROMPT" > "$PROMPT_PATH"
|
|
fi
|
|
}
|
|
|
|
# =====================================================================
|
|
# Config load (src/config.c:162)
|
|
# =====================================================================
|
|
|
|
config_load() {
|
|
# Read each field with jq, falling back to defaults (src/config.c:174)
|
|
CFG_MODEL="$(jq -r --arg d "$DEFAULT_MODEL" '.model // $d' "$CONFIG_PATH")"
|
|
CFG_API_BASE="$(jq -r --arg d "$DEFAULT_API_BASE" '.api_base // $d' "$CONFIG_PATH")"
|
|
CFG_MAX_COST="$(jq -r --argjson d "$DEFAULT_MAX_COST" '.max_cost // $d' "$CONFIG_PATH")"
|
|
CFG_MAX_STEPS="$(jq -r --argjson d "$DEFAULT_MAX_STEPS" '.max_steps // $d' "$CONFIG_PATH")"
|
|
CFG_TIMEOUT="$(jq -r --argjson d "$DEFAULT_TIMEOUT" '.timeout // $d' "$CONFIG_PATH")"
|
|
CFG_CMD_TIMEOUT="$(jq -r --argjson d "$DEFAULT_CMD_TIMEOUT" '.cmd_timeout // $d' "$CONFIG_PATH")"
|
|
CFG_MAX_OUTPUT_TOKENS="$(jq -r --argjson d "$DEFAULT_MAX_OUTPUT_TOKENS" '.max_output_tokens // $d' "$CONFIG_PATH")"
|
|
CFG_TEMPERATURE="$(jq -r --argjson d "$DEFAULT_TEMPERATURE" '.temperature // $d' "$CONFIG_PATH")"
|
|
|
|
# Apply CLI overrides (src/main.c:186)
|
|
[ -n "$MODEL_OVERRIDE" ] && CFG_MODEL="$MODEL_OVERRIDE"
|
|
[ "$HAS_MAX_COST" = "1" ] && CFG_MAX_COST="$MAX_COST_OVERRIDE"
|
|
[ "$HAS_MAX_STEPS" = "1" ] && CFG_MAX_STEPS="$MAX_STEPS_OVERRIDE"
|
|
[ "$HAS_TIMEOUT" = "1" ] && CFG_TIMEOUT="$TIMEOUT_OVERRIDE"
|
|
[ "$HAS_CMD_TIMEOUT" = "1" ] && CFG_CMD_TIMEOUT="$CMD_TIMEOUT_OVERRIDE"
|
|
}
|
|
|
|
# Load system prompt: per-project file if present, else default (src/main.c:201)
|
|
load_system_prompt() {
|
|
if [ -f "$PROMPT_PATH" ]; then
|
|
cat "$PROMPT_PATH"
|
|
else
|
|
printf '%s\n' "$DEFAULT_SYSTEM_PROMPT"
|
|
fi
|
|
}
|
|
|
|
# =====================================================================
|
|
# Logger (src/logger.c)
|
|
# =====================================================================
|
|
|
|
# Unix epoch seconds. Compact, timezone-free (epoch is universal), and
|
|
# reveals no time-of-day. Convert to a human-readable UTC time later with
|
|
# `date -u -d @1754237295` if needed.
|
|
log_timestamp() {
|
|
date +%s
|
|
}
|
|
|
|
# Append a pre-built JSON line and flush (src/logger.c:59 write_and_flush).
|
|
# Accepts the line either as $1 or via stdin (so callers can pipe jq output).
|
|
log_append() {
|
|
if [ -n "$1" ]; then
|
|
line="$1"
|
|
else
|
|
line="$(cat)"
|
|
fi
|
|
[ -z "$line" ] && return 0
|
|
printf '%s\n' "$line" >> "$LOG_PATH"
|
|
sync "$LOG_PATH" 2>/dev/null || true
|
|
}
|
|
|
|
# step entry (src/logger.c:83)
|
|
# Model response entry — logs what the model decided (reasoning, command to
|
|
# run, or done summary). One entry per API response. This is logged BEFORE
|
|
# any command execution (flush-before-act: if we crash mid-execution, the
|
|
# log shows the model's decision without a matching exec entry).
|
|
# Args: step mode model reasoning command done_summary tokens_in tokens_out step_cost cum_cost elapsed
|
|
log_response() {
|
|
step="$1"; mode="$2"; model="$3"; reasoning="$4"; command="$5"
|
|
done_summary="$6"; tokens_in="$7"; tokens_out="$8"
|
|
step_cost="$9"; cum_cost="${10}"; elapsed="${11}"
|
|
ts="$(log_timestamp)"
|
|
jq -nc \
|
|
--argjson ts "$ts" --argjson step "$step" --arg mode "$mode" --arg model "$model" \
|
|
--arg reasoning "$reasoning" --arg command "$command" --arg done_summary "$done_summary" \
|
|
--argjson tin "$tokens_in" --argjson tout "$tokens_out" \
|
|
--argjson scost "$step_cost" --argjson ccost "$cum_cost" \
|
|
--argjson el "$elapsed" --arg source "model" \
|
|
'{timestamp:$ts, source:$source, event:"response", step:$step, mode:$mode, model:$model,
|
|
reasoning:$reasoning, command:$command, done_summary:$done_summary,
|
|
tokens_in:$tin, tokens_out:$tout,
|
|
step_cost:$scost, cumulative_cost:$ccost, elapsed_seconds:$el}' \
|
|
| log_append
|
|
}
|
|
|
|
# Command execution entry — logs what aish did when it ran a command.
|
|
# One entry per run_shell execution. Logged AFTER execution completes.
|
|
# Args: step stdout stderr exit_code cum_cost
|
|
log_exec() {
|
|
step="$1"; out="$2"; err="$3"; exit_code="$4"; cum_cost="$5"
|
|
ts="$(log_timestamp)"
|
|
jq -nc \
|
|
--argjson ts "$ts" --argjson step "$step" --arg event "exec" \
|
|
--arg out "$out" --arg err "$err" --argjson ec "$exit_code" \
|
|
--argjson cc "$cum_cost" --arg source "aish" \
|
|
'{timestamp:$ts, source:$source, event:$event, step:$step,
|
|
command_output:$out, command_stderr:$err, exit_code:$ec,
|
|
cumulative_cost:$cc}' \
|
|
| log_append
|
|
}
|
|
|
|
# user_input entry (src/logger.c:150)
|
|
log_user_input() {
|
|
text="$1"
|
|
ts="$(log_timestamp)"
|
|
jq -nc --argjson ts "$ts" --arg event "user_input" --arg text "$text" \
|
|
--arg source "user" \
|
|
'{timestamp:$ts, source:$source, event:$event, text:$text}' | log_append
|
|
}
|
|
|
|
# generic event entry (src/logger.c:166)
|
|
# Args: event detail_key detail_value step cum_cost
|
|
log_event() {
|
|
event="$1"; dkey="$2"; dval="$3"; step="$4"; cum_cost="$5"
|
|
ts="$(log_timestamp)"
|
|
if [ -n "$dkey" ] && [ -n "$dval" ]; then
|
|
jq -nc --argjson ts "$ts" --arg event "$event" --arg dkey "$dkey" --arg dval "$dval" \
|
|
--argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \
|
|
'{timestamp:$ts, source:$source, event:$event, ($dkey):$dval, step:$step, cumulative_cost:$cc}' \
|
|
| log_append
|
|
else
|
|
jq -nc --argjson ts "$ts" --arg event "$event" \
|
|
--argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \
|
|
'{timestamp:$ts, source:$source, event:$event, step:$step, cumulative_cost:$cc}' \
|
|
| log_append
|
|
fi
|
|
}
|
|
|
|
# api_error entry (src/agent_loop.c:185)
|
|
# Args: step retries error
|
|
log_api_error() {
|
|
step="$1"; retries="$2"; error="$3"
|
|
ts="$(log_timestamp)"
|
|
jq -nc --argjson ts "$ts" --arg event "api_error" --argjson step "$step" \
|
|
--argjson retries "$retries" --arg error "$error" --arg source "aish" \
|
|
'{timestamp:$ts, source:$source, event:"api_error", step:$step, retries:$retries, error:$error}' \
|
|
| log_append
|
|
}
|
|
|
|
# =====================================================================
|
|
# Conversation helpers
|
|
# =====================================================================
|
|
|
|
# Messages are accumulated in $MESSAGES_FILE, one JSON object per line.
|
|
# The system prompt is the first message (src/resume.c:64).
|
|
|
|
conv_init() {
|
|
MESSAGES_FILE="$(mktemp)"
|
|
# system message
|
|
jq -nc --arg content "$1" '{role:"system", content:$content}' >> "$MESSAGES_FILE"
|
|
}
|
|
|
|
conv_append_user() {
|
|
jq -nc --arg content "$1" '{role:"user", content:$content}' >> "$MESSAGES_FILE"
|
|
}
|
|
|
|
conv_append_assistant_text() {
|
|
# assistant message with content only (src/agent_loop.c:262)
|
|
jq -nc --arg content "$1" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE"
|
|
}
|
|
|
|
conv_append_assistant_with_tools() {
|
|
# assistant message with tool_calls (src/agent_loop.c:382)
|
|
# $1 = reasoning (may be empty), $2 = path to a file of tool_call JSON objects (one per line)
|
|
reasoning="$1"
|
|
tc_file="$2"
|
|
if [ -s "$tc_file" ]; then
|
|
tool_calls="$(jq -s '.' "$tc_file")"
|
|
jq -nc --arg content "$reasoning" --argjson tcs "$tool_calls" \
|
|
'{role:"assistant", content:$content, tool_calls:$tcs}' >> "$MESSAGES_FILE"
|
|
else
|
|
jq -nc --arg content "$reasoning" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE"
|
|
fi
|
|
}
|
|
|
|
conv_append_tool() {
|
|
# tool-role result message (src/agent_loop.c:358)
|
|
# $1 = tool_call_id, $2 = tool name, $3 = result content (string)
|
|
jq -nc --arg id "$1" --arg name "$2" --arg content "$3" \
|
|
'{role:"tool", tool_call_id:$id, name:$name, content:$content}' >> "$MESSAGES_FILE"
|
|
}
|
|
|
|
# =====================================================================
|
|
# Resume (src/resume.c:57)
|
|
# =====================================================================
|
|
|
|
# Replay log.jsonl into the messages file. Drops incomplete steps.
|
|
# Sets RESUME_LAST_STEP and RESUME_CUM_COST.
|
|
RESUME_LAST_STEP=0
|
|
RESUME_CUM_COST=0.0
|
|
|
|
resume_from_log() {
|
|
system_prompt="$1"
|
|
conv_init "$system_prompt"
|
|
|
|
[ -f "$LOG_PATH" ] || return 0
|
|
|
|
last_complete_step=0
|
|
pending_command=""
|
|
pending_reasoning=""
|
|
pending_step=""
|
|
|
|
# Read line by line. Lines can be large (command output), so avoid
|
|
# `while read` word-splitting issues by using a temp file + jq.
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
[ -z "$line" ] && continue
|
|
|
|
# Parse fields with jq. Use //empty to get empty string on null.
|
|
event="$(printf '%s' "$line" | jq -r '.event // empty' 2>/dev/null)" || continue
|
|
step="$(printf '%s' "$line" | jq -r '.step // 0' 2>/dev/null)" || step=0
|
|
cum="$(printf '%s' "$line" | jq -r '.cumulative_cost // 0' 2>/dev/null)" || cum=0
|
|
|
|
# Track last seen cumulative cost (src/resume.c:97)
|
|
if [ -n "$cum" ]; then
|
|
case "$cum" in
|
|
''|*[!0-9.]*) ;;
|
|
*) RESUME_CUM_COST="$cum" ;;
|
|
esac
|
|
fi
|
|
|
|
# user_input event — replay as user message
|
|
if [ "$event" = "user_input" ]; then
|
|
text="$(printf '%s' "$line" | jq -r '.text // empty')"
|
|
[ -n "$text" ] && conv_append_user "$text"
|
|
continue
|
|
fi
|
|
|
|
# exec event — command execution result from aish.
|
|
# Pair it with the preceding response entry that had a command.
|
|
if [ "$event" = "exec" ]; then
|
|
exec_output="$(printf '%s' "$line" | jq -r '.command_output // empty' 2>/dev/null)"
|
|
exec_stderr="$(printf '%s' "$line" | jq -r '.command_stderr // empty' 2>/dev/null)"
|
|
exec_exit="$(printf '%s' "$line" | jq -r '.exit_code // 0' 2>/dev/null)"
|
|
# If we have a pending response with a command, complete it
|
|
if [ -n "$pending_command" ]; then
|
|
call_id="call_${pending_step}"
|
|
tc_tmp="$(mktemp)"
|
|
jq -nc --arg id "$call_id" --arg cmd "$pending_command" \
|
|
'{type:"function", id:$id, function:{name:"run_shell", arguments:({"command":$cmd}|tojson)}}' \
|
|
>> "$tc_tmp"
|
|
conv_append_assistant_with_tools "$pending_reasoning" "$tc_tmp"
|
|
rm -f "$tc_tmp"
|
|
result_content="$(jq -nc --arg out "$exec_output" --arg err "$exec_stderr" \
|
|
--argjson ec "$exec_exit" \
|
|
'{stdout:$out, stderr:$err, exit_code:$ec}')"
|
|
conv_append_tool "$call_id" "run_shell" "$result_content"
|
|
last_complete_step="$pending_step"
|
|
pending_command=""
|
|
pending_reasoning=""
|
|
pending_step=""
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
# api_error / budget events — not conversation content
|
|
if [ "$event" = "api_error" ] || [ "$event" = "cost budget exceeded" ] \
|
|
|| [ "$event" = "max steps reached" ] || [ "$event" = "timeout reached" ]; then
|
|
continue
|
|
fi
|
|
|
|
# response event — model's decision (reasoning, command, or done)
|
|
if [ "$event" = "response" ]; then
|
|
reasoning="$(printf '%s' "$line" | jq -r '.reasoning // empty' 2>/dev/null)" || reasoning=""
|
|
command="$(printf '%s' "$line" | jq -r '.command // empty' 2>/dev/null)" || command=""
|
|
done_summary="$(printf '%s' "$line" | jq -r '.done_summary // empty' 2>/dev/null)" || done_summary=""
|
|
|
|
if [ -n "$command" ]; then
|
|
# Model requested a command — stash and wait for exec entry
|
|
pending_command="$command"
|
|
pending_reasoning="$reasoning"
|
|
pending_step="$step"
|
|
elif [ -n "$done_summary" ]; then
|
|
# Model called done() — the response entry is the final answer.
|
|
# Don't replay done() as conversation; it ends the session.
|
|
last_complete_step="$step"
|
|
elif [ -n "$reasoning" ]; then
|
|
# Assistant text-only message (no command, no done)
|
|
conv_append_assistant_text "$reasoning"
|
|
last_complete_step="$step"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
# Legacy: old-format "done" and "step_result" events from prior logs
|
|
if [ "$event" = "done" ] || [ "$event" = "step_result" ]; then
|
|
continue
|
|
fi
|
|
done < "$LOG_PATH"
|
|
|
|
RESUME_LAST_STEP="$last_complete_step"
|
|
}
|
|
|
|
# =====================================================================
|
|
# Budget (src/budget.c)
|
|
# =====================================================================
|
|
|
|
budget_init() {
|
|
BUDGET_MAX_COST="$1"
|
|
BUDGET_MAX_STEPS="$2"
|
|
BUDGET_TIMEOUT="$3"
|
|
BUDGET_COST="$4" # seeded from resume
|
|
BUDGET_STEP="$5" # seeded from resume
|
|
BUDGET_START="$(date +%s)"
|
|
}
|
|
|
|
budget_elapsed() {
|
|
now="$(date +%s)"
|
|
echo $(( now - BUDGET_START ))
|
|
}
|
|
|
|
# Returns 0 (true) if budget ok, 1 (false) if exceeded; sets BUDGET_REASON.
|
|
# Order matches src/budget.c:31.
|
|
budget_check() {
|
|
# cost
|
|
if awk "BEGIN { exit !($BUDGET_COST >= $BUDGET_MAX_COST) }"; then
|
|
BUDGET_REASON="cost budget exceeded"
|
|
return 1
|
|
fi
|
|
# steps
|
|
if [ "$BUDGET_STEP" -ge "$BUDGET_MAX_STEPS" ]; then
|
|
BUDGET_REASON="max steps reached"
|
|
return 1
|
|
fi
|
|
# time
|
|
if [ "$BUDGET_TIMEOUT" -gt 0 ]; then
|
|
el="$(budget_elapsed)"
|
|
if [ "$el" -gt "$BUDGET_TIMEOUT" ]; then
|
|
BUDGET_REASON="timeout reached"
|
|
return 1
|
|
fi
|
|
fi
|
|
BUDGET_REASON=""
|
|
return 0
|
|
}
|
|
|
|
# Estimate cost for a step (src/budget.c:59). Args: model tokens_in tokens_out
|
|
budget_estimate_cost() {
|
|
model="$1"; tin="$2"; tout="$3"
|
|
case "$model" in
|
|
glm-5.2) in_rate=0.85; out_rate=2.68 ;;
|
|
gpt-5.3-codex) in_rate=1.85; out_rate=14.77 ;;
|
|
gpt-4o) in_rate=2.50; out_rate=10.00 ;;
|
|
gpt-4o-mini) in_rate=0.15; out_rate=0.60 ;;
|
|
claude-3.5-sonnet) in_rate=3.00; out_rate=15.00 ;;
|
|
*) in_rate=1.0; out_rate=5.0 ;;
|
|
esac
|
|
# cost = (tin*in + tout*out) / 1e6
|
|
awk "BEGIN { printf \"%.6f\", ($tin * $in_rate + $tout * $out_rate) / 1000000.0 }"
|
|
}
|
|
|
|
# =====================================================================
|
|
# Shell exec (src/shell_exec.c:39)
|
|
# =====================================================================
|
|
|
|
# Run a command with timeout, capture stdout/stderr/exit separately.
|
|
# Sets SH_OUT, SH_ERR, SH_EXIT. Truncates output. Handles empty + timeout.
|
|
# Args: command timeout_seconds
|
|
shell_exec() {
|
|
command="$1"
|
|
timeout_sec="$2"
|
|
[ "$timeout_sec" -le 0 ] 2>/dev/null && timeout_sec="$DEFAULT_CMD_TIMEOUT"
|
|
|
|
# Empty command (src/shell_exec.c:45)
|
|
if [ -z "$command" ]; then
|
|
SH_OUT=""
|
|
SH_ERR="[empty command]"
|
|
SH_EXIT=-1
|
|
return
|
|
fi
|
|
|
|
err_tmp="$(mktemp)"
|
|
# timeout kills the whole process group (src/shell_exec.c:117 killpg)
|
|
# --signal=KILL --kill-after=2 ensures the tree dies.
|
|
SH_OUT="$(timeout --signal=KILL --kill-after=2 "$timeout_sec" sh -c "$command" 2>"$err_tmp")"
|
|
SH_EXIT=$?
|
|
SH_ERR="$(cat "$err_tmp")"
|
|
rm -f "$err_tmp"
|
|
|
|
# timeout(1) exits 124 on timeout, 137 on KILL. Normalize (src/shell_exec.c:120)
|
|
if [ "$SH_EXIT" = "124" ] || [ "$SH_EXIT" = "137" ]; then
|
|
SH_EXIT=-1
|
|
if [ -z "$SH_ERR" ]; then
|
|
SH_ERR="[command timed out — process group killed]"
|
|
fi
|
|
fi
|
|
|
|
# Truncate stdout (src/shell_exec.c:183)
|
|
if [ -n "$SH_OUT" ]; then
|
|
out_len="$(printf '%s' "$SH_OUT" | wc -c)"
|
|
if [ "$out_len" -gt "$MAX_STDOUT_CHARS" ]; then
|
|
SH_OUT="$(printf '%s' "$SH_OUT" | head -c "$MAX_STDOUT_CHARS")
|
|
...[truncated at ${MAX_STDOUT_CHARS} chars]"
|
|
fi
|
|
fi
|
|
# Truncate stderr (src/shell_exec.c:199)
|
|
if [ -n "$SH_ERR" ]; then
|
|
err_len="$(printf '%s' "$SH_ERR" | wc -c)"
|
|
if [ "$err_len" -gt "$MAX_STDERR_CHARS" ]; then
|
|
SH_ERR="$(printf '%s' "$SH_ERR" | head -c "$MAX_STDERR_CHARS")
|
|
...[truncated at ${MAX_STDERR_CHARS} chars]"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# =====================================================================
|
|
# API request building (src/json_util.c)
|
|
# =====================================================================
|
|
|
|
# Build the tools array (constant). Matches src/json_util.c:12.
|
|
build_tools_json() {
|
|
jq -nc \
|
|
--arg rsdesc "$TOOL_RUN_SHELL_DESC" --arg ddesc "$TOOL_DONE_DESC" \
|
|
'[
|
|
{type:"function",
|
|
function:{name:"run_shell", description:$rsdesc,
|
|
parameters:{type:"object",
|
|
properties:{command:{type:"string",description:"The shell command to execute."},
|
|
timeout_seconds:{type:"integer",description:"Optional timeout for this command in seconds (default 60)."}},
|
|
required:["command"]}}},
|
|
{type:"function",
|
|
function:{name:"done", description:$ddesc,
|
|
parameters:{type:"object",
|
|
properties:{summary:{type:"string",description:"A brief summary of the outcome."}},
|
|
required:["summary"]}}}
|
|
]'
|
|
}
|
|
|
|
# Build the full request body (src/json_util.c:77).
|
|
# Args: model max_tokens temperature
|
|
build_request_body() {
|
|
model="$1"; max_tokens="$2"; temperature="$3"
|
|
tools_json="$(build_tools_json)"
|
|
messages_json="$(jq -s '.' "$MESSAGES_FILE")"
|
|
jq -nc \
|
|
--arg model "$model" --argjson messages "$messages_json" \
|
|
--argjson tools "$tools_json" --argjson maxtok "$max_tokens" \
|
|
--argjson temp "$temperature" \
|
|
'{model:$model, messages:$messages, tools:$tools,
|
|
tool_choice:"auto", temperature:$temp, max_tokens:$maxtok}'
|
|
}
|
|
|
|
# =====================================================================
|
|
# API client (src/api_client.c:93)
|
|
# =====================================================================
|
|
|
|
# Call the API with retry + backoff. Sets API_RESPONSE (body) on success.
|
|
# Returns 0 on success, 1 on failure. Logs api_error on final failure.
|
|
# Args: step (for logging)
|
|
api_call() {
|
|
step="$1"
|
|
# Strip trailing slash from api_base (src/api_client.c:110)
|
|
base="${CFG_API_BASE%/}"
|
|
url="${base}/chat/completions"
|
|
|
|
body="$(build_request_body "$CFG_MODEL" "$CFG_MAX_OUTPUT_TOKENS" "$CFG_TEMPERATURE")"
|
|
|
|
attempt=1
|
|
while [ "$attempt" -le "$API_MAX_RETRIES" ]; do
|
|
# curl: -sS silent but show errors, --max-time overall timeout,
|
|
# -w '\n%{http_code}' appends HTTP status as last line.
|
|
resp_with_code="$(printf '%s' "$body" | \
|
|
curl -sS -X POST "$url" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $API_KEY" \
|
|
--data-binary @- \
|
|
--max-time "$API_TIMEOUT_SECONDS" \
|
|
-w '\n%{http_code}' 2>&1)" || curl_rc=$?
|
|
|
|
curl_rc=${curl_rc:-0}
|
|
|
|
if [ "$curl_rc" != "0" ]; then
|
|
# curl-level failure (network, timeout) — retry
|
|
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
|
|
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
|
|
attempt=$((attempt+1))
|
|
continue
|
|
fi
|
|
log_api_error "$step" "$API_MAX_RETRIES" "curl error: rc=$curl_rc"
|
|
return 1
|
|
fi
|
|
|
|
# Split body and status (last line is the http code)
|
|
http_code="$(printf '%s' "$resp_with_code" | tail -n1)"
|
|
resp_body="$(printf '%s' "$resp_with_code" | sed '$d')"
|
|
|
|
case "$http_code" in
|
|
2*)
|
|
# Check for an error object in the body — some providers
|
|
# return HTTP 200 with {"error":{...}} on rate limits etc.
|
|
err_msg="$(printf '%s' "$resp_body" | jq -r '.error.message // .error // empty' 2>/dev/null)"
|
|
if [ -n "$err_msg" ]; then
|
|
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
|
|
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
|
|
attempt=$((attempt+1))
|
|
continue
|
|
fi
|
|
log_api_error "$step" "$API_MAX_RETRIES" "API error (HTTP $http_code): $err_msg"
|
|
return 1
|
|
fi
|
|
API_RESPONSE="$resp_body"
|
|
return 0
|
|
;;
|
|
*)
|
|
# Non-2xx — retry (mirrors C: retries on any non-2xx)
|
|
if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then
|
|
sleep "$(( API_BACKOFF_BASE_SEC * attempt ))"
|
|
attempt=$((attempt+1))
|
|
continue
|
|
fi
|
|
log_api_error "$step" "$API_MAX_RETRIES" "HTTP $http_code: $resp_body"
|
|
return 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
log_api_error "$step" "$API_MAX_RETRIES" "no attempts succeeded"
|
|
return 1
|
|
}
|
|
|
|
# =====================================================================
|
|
# Response parsing (src/json_util.c:128)
|
|
# =====================================================================
|
|
|
|
# Parse API_RESPONSE into globals:
|
|
# RESP_CONTENT, RESP_TOKENS_IN, RESP_TOKENS_OUT
|
|
# RESP_TOOL_CALLS_FILE — temp file with one tool_call object per line:
|
|
# {id, name, arguments} (arguments is the raw JSON string from the API)
|
|
# RESP_TOOL_CALL_COUNT
|
|
parse_response() {
|
|
RESP_CONTENT="$(printf '%s' "$API_RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null)" || RESP_CONTENT=""
|
|
RESP_TOKENS_IN="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.prompt_tokens // 0' 2>/dev/null)" || RESP_TOKENS_IN=0
|
|
RESP_TOKENS_OUT="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.completion_tokens // 0' 2>/dev/null)" || RESP_TOKENS_OUT=0
|
|
|
|
RESP_TOOL_CALLS_FILE="$(mktemp)"
|
|
# Extract each tool call as {id, name, arguments} (src/json_util.c:154)
|
|
printf '%s' "$API_RESPONSE" | jq -c '.choices[0].message.tool_calls[]? |
|
|
{id:(.id // "call"), name:(.function.name // ""), arguments:(.function.arguments // "{}")}' \
|
|
> "$RESP_TOOL_CALLS_FILE" 2>/dev/null || true
|
|
|
|
RESP_TOOL_CALL_COUNT=0
|
|
if [ -s "$RESP_TOOL_CALLS_FILE" ]; then
|
|
RESP_TOOL_CALL_COUNT="$(wc -l < "$RESP_TOOL_CALLS_FILE")"
|
|
fi
|
|
}
|
|
|
|
# Get a string field from a tool call's arguments JSON. Args: arguments_json field
|
|
parse_arg_string() {
|
|
printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null
|
|
}
|
|
|
|
# Get an int field from a tool call's arguments JSON. Args: arguments_json field fallback
|
|
parse_arg_int() {
|
|
val="$(printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null)"
|
|
[ -z "$val" ] && echo "$3" || echo "$val"
|
|
}
|
|
|
|
# =====================================================================
|
|
# Display helpers (src/agent_loop.c:28)
|
|
# =====================================================================
|
|
|
|
# Truncate to 120 chars with ellipsis (src/agent_loop.c:32)
|
|
truncate_display() {
|
|
s="$1"
|
|
len="$(printf '%s' "$s" | wc -c)"
|
|
if [ "$len" -gt 120 ]; then
|
|
printf '%s' "$s" | head -c 117
|
|
printf '...'
|
|
else
|
|
printf '%s' "$s"
|
|
fi
|
|
}
|
|
|
|
# Print a status line to stderr only in --verbose mode. Status lines are
|
|
# diagnostics (loaded/step/budget/done/finished); the actual response goes
|
|
# to stdout via print_response so it can be piped cleanly.
|
|
status() {
|
|
[ "$VERBOSE" = "1" ] || return 0
|
|
printf '%s\n' "$*" >&2
|
|
}
|
|
|
|
print_step() {
|
|
step="$1"; command="$2"; reasoning="$3"
|
|
if [ -n "$command" ]; then
|
|
disp="$(truncate_display "$command")"
|
|
status "[step $step] \$ $disp"
|
|
elif [ -n "$reasoning" ]; then
|
|
disp="$(truncate_display "$reasoning")"
|
|
status "[step $step] (reasoning) $disp"
|
|
else
|
|
status "[step $step]"
|
|
fi
|
|
}
|
|
|
|
print_response() {
|
|
# The response is the real payload — always goes to stdout.
|
|
# Default (quiet): print it raw so output is clean.
|
|
# Verbose: prefix with [response] for consistency with other status lines.
|
|
[ -z "$1" ] && return 0
|
|
if [ "$VERBOSE" = "1" ]; then
|
|
printf '[response] %s\n' "$1"
|
|
else
|
|
printf '%s\n' "$1"
|
|
fi
|
|
}
|
|
|
|
# =====================================================================
|
|
# Agent loop (src/agent_loop.c:117)
|
|
# =====================================================================
|
|
|
|
agent_loop_run() {
|
|
result=1 # default failure
|
|
|
|
while true; do
|
|
# 1. Check budget (src/agent_loop.c:133)
|
|
if ! budget_check; then
|
|
status "[budget] ${BUDGET_REASON:-limit reached} — stopping"
|
|
log_event "$BUDGET_REASON" "" "" "$BUDGET_STEP" "$BUDGET_COST"
|
|
result=0 # normal stop
|
|
break
|
|
fi
|
|
|
|
BUDGET_STEP=$((BUDGET_STEP + 1))
|
|
|
|
# 3-4. Call the API (src/agent_loop.c:175)
|
|
if ! api_call "$BUDGET_STEP"; then
|
|
status "[error] API call failed (see log)"
|
|
result=1
|
|
break
|
|
fi
|
|
|
|
# 4. Parse response (src/agent_loop.c:191)
|
|
parse_response
|
|
|
|
# Detect empty responses: no content and no tool calls.
|
|
# No hidden prompting — just report the error and stop.
|
|
if [ -z "$RESP_CONTENT" ] && [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then
|
|
echo "[error] empty response from API (no content, no tool calls)" >&2
|
|
log_api_error "$BUDGET_STEP" 3 "empty response: $API_RESPONSE"
|
|
result=1
|
|
break
|
|
fi
|
|
|
|
# 5. Calculate cost (src/agent_loop.c:203)
|
|
step_cost="$(budget_estimate_cost "$CFG_MODEL" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT")"
|
|
BUDGET_COST="$(awk "BEGIN { printf \"%.6f\", $BUDGET_COST + $step_cost }")"
|
|
|
|
# 6. Scan tool calls to extract log_command and done_summary BEFORE
|
|
# logging. This lets us write one model entry that captures the full
|
|
# model response (reasoning + command + done summary) in a single
|
|
# log line. Flush-before-act: logged before any command execution.
|
|
log_command=""
|
|
pre_done_summary=""
|
|
if [ "$RESP_TOOL_CALL_COUNT" -gt 0 ]; then
|
|
idx=0
|
|
while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do
|
|
tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")"
|
|
tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')"
|
|
tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')"
|
|
if [ "$tc_name" = "run_shell" ] && [ -z "$log_command" ]; then
|
|
log_command="$(parse_arg_string "$tc_args" "command")"
|
|
fi
|
|
if [ "$tc_name" = "done" ]; then
|
|
pre_done_summary="$(parse_arg_string "$tc_args" "summary")"
|
|
fi
|
|
idx=$((idx+1))
|
|
done
|
|
fi
|
|
|
|
elapsed="$(budget_elapsed)"
|
|
log_response "$BUDGET_STEP" "$MODE" "$CFG_MODEL" "$RESP_CONTENT" "$log_command" \
|
|
"$pre_done_summary" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT" \
|
|
"$step_cost" "$BUDGET_COST" "$elapsed"
|
|
|
|
# 7. Print step info (src/agent_loop.c:244)
|
|
print_step "$BUDGET_STEP" "$log_command" "$RESP_CONTENT"
|
|
|
|
# 8. No tool calls — text response (src/agent_loop.c:259)
|
|
if [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then
|
|
conv_append_assistant_text "$RESP_CONTENT"
|
|
print_response "$RESP_CONTENT"
|
|
rm -f "$RESP_TOOL_CALLS_FILE"
|
|
|
|
# Text response with no tool calls = done (both standalone and
|
|
# autonomous). No hidden prompting to continue.
|
|
result=0
|
|
break
|
|
fi
|
|
|
|
# 9. Process tool calls (src/agent_loop.c:290)
|
|
done_called=0
|
|
done_summary=""
|
|
tc_objs_file="$(mktemp)" # tool_call objects for the assistant message
|
|
|
|
idx=0
|
|
while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do
|
|
tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")"
|
|
tc_id="$(printf '%s' "$tc_line" | jq -r '.id // empty')"
|
|
tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')"
|
|
tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')"
|
|
|
|
[ -z "$tc_name" ] && { idx=$((idx+1)); continue; }
|
|
|
|
if [ "$tc_name" = "done" ]; then
|
|
done_called=1
|
|
done_summary="$(parse_arg_string "$tc_args" "summary")"
|
|
# Still add to conversation (src/agent_loop.c:309)
|
|
jq -nc --arg id "${tc_id:-done}" --arg args "$tc_args" \
|
|
'{type:"function", id:$id, function:{name:"done", arguments:$args}}' >> "$tc_objs_file"
|
|
idx=$((idx+1))
|
|
continue
|
|
fi
|
|
|
|
if [ "$tc_name" = "run_shell" ]; then
|
|
command="$(parse_arg_string "$tc_args" "command")"
|
|
cmd_timeout="$(parse_arg_int "$tc_args" "timeout_seconds" "$CFG_CMD_TIMEOUT")"
|
|
|
|
if [ -z "$command" ]; then
|
|
# Empty command (src/agent_loop.c:322)
|
|
jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \
|
|
'{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file"
|
|
result_json="$(jq -nc --arg out "" --arg err "[empty command]" --argjson ec -1 \
|
|
'{stdout:$out, stderr:$err, exit_code:$ec}')"
|
|
conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json"
|
|
idx=$((idx+1))
|
|
continue
|
|
fi
|
|
|
|
# Execute (src/agent_loop.c:338)
|
|
shell_exec "$command" "$cmd_timeout"
|
|
|
|
# Log execution result AFTER execution (flush-before-act)
|
|
log_exec "$BUDGET_STEP" "$SH_OUT" "$SH_ERR" "$SH_EXIT" "$BUDGET_COST"
|
|
|
|
# Add to conversation (src/agent_loop.c:347)
|
|
jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \
|
|
'{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file"
|
|
result_json="$(jq -nc --arg out "$SH_OUT" --arg err "$SH_ERR" --argjson ec "$SH_EXIT" \
|
|
'{stdout:$out, stderr:$err, exit_code:$ec}')"
|
|
conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json"
|
|
|
|
idx=$((idx+1))
|
|
continue
|
|
fi
|
|
|
|
# Unknown tool (src/agent_loop.c:367)
|
|
err_msg="unknown tool: $tc_name"
|
|
jq -nc --arg id "${tc_id:-unknown}" --arg name "$tc_name" --arg args "$tc_args" \
|
|
'{type:"function", id:$id, function:{name:$name, arguments:$args}}' >> "$tc_objs_file"
|
|
result_json="$(jq -nc --arg err "$err_msg" --argjson ec -1 \
|
|
'{stdout:"", stderr:$err, exit_code:$ec}')"
|
|
conv_append_tool "${tc_id:-unknown}" "$tc_name" "$result_json"
|
|
idx=$((idx+1))
|
|
done
|
|
|
|
# 11. Append assistant message with tool_calls (src/agent_loop.c:382)
|
|
conv_append_assistant_with_tools "$RESP_CONTENT" "$tc_objs_file"
|
|
rm -f "$tc_objs_file"
|
|
rm -f "$RESP_TOOL_CALLS_FILE"
|
|
|
|
# 12. done() handling — print the model's reasoning (the actual answer)
|
|
# to the screen. Fall back to done_summary only if reasoning is empty.
|
|
if [ "$done_called" = "1" ]; then
|
|
final_answer="$RESP_CONTENT"
|
|
[ -z "$final_answer" ] && final_answer="$done_summary"
|
|
[ -z "$final_answer" ] && final_answer="task complete"
|
|
print_response "$final_answer"
|
|
status "[done] $final_answer"
|
|
result=0
|
|
break
|
|
fi
|
|
done
|
|
|
|
return $result
|
|
}
|
|
|
|
# =====================================================================
|
|
# Main flow — runs when executed directly (./ai.sh ...).
|
|
# All functionality (ask + command-generation modes) is handled here;
|
|
# the script is no longer intended to be sourced.
|
|
# =====================================================================
|
|
|
|
# Arg parsing + mode detection
|
|
PROG_NAME="$(basename "$0")"
|
|
|
|
if [ "$PROG_NAME" = "aa" ]; then
|
|
echo "error: interactive server mode (aa) is not yet implemented" >&2
|
|
echo " use: $PROG_NAME \"message\" (one-shot)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
i=1
|
|
while [ $i -le $# ]; do
|
|
eval "arg=\"\${$i}\""
|
|
case "$arg" in
|
|
--help|-h)
|
|
usage "$PROG_NAME"
|
|
exit 0
|
|
;;
|
|
--verbose|-v)
|
|
VERBOSE=1
|
|
i=$((i+1))
|
|
;;
|
|
--autonomous|-a)
|
|
IS_AUTONOMOUS=1
|
|
i=$((i+1))
|
|
;;
|
|
--cmd|-c)
|
|
CMD_MODE=1
|
|
i=$((i+1))
|
|
;;
|
|
--init|-i)
|
|
INIT_MODE=1
|
|
i=$((i+1))
|
|
;;
|
|
--clear|-x|--reset|-r)
|
|
CLEAR_MODE=1
|
|
i=$((i+1))
|
|
;;
|
|
--tail|-t)
|
|
TAIL_MODE=1
|
|
i=$((i+1))
|
|
;;
|
|
--model|-m)
|
|
i=$((i+1)); eval "MODEL_OVERRIDE=\"\${$i}\""; i=$((i+1))
|
|
;;
|
|
--api-key|-k)
|
|
i=$((i+1)); eval "API_KEY_OVERRIDE=\"\${$i}\""; i=$((i+1))
|
|
;;
|
|
--max-cost|-C)
|
|
i=$((i+1)); eval "MAX_COST_OVERRIDE=\"\${$i}\""
|
|
HAS_MAX_COST=1; i=$((i+1))
|
|
;;
|
|
--max-steps|-s)
|
|
i=$((i+1)); eval "MAX_STEPS_OVERRIDE=\"\${$i}\""
|
|
HAS_MAX_STEPS=1; i=$((i+1))
|
|
;;
|
|
--timeout)
|
|
i=$((i+1)); eval "TIMEOUT_OVERRIDE=\"\${$i}\""
|
|
HAS_TIMEOUT=1; i=$((i+1))
|
|
;;
|
|
--cmd-timeout|-T)
|
|
i=$((i+1)); eval "CMD_TIMEOUT_OVERRIDE=\"\${$i}\""
|
|
HAS_CMD_TIMEOUT=1; i=$((i+1))
|
|
;;
|
|
--resume)
|
|
RESUME=1; i=$((i+1))
|
|
;;
|
|
--no-resume|-R)
|
|
RESUME=0; i=$((i+1))
|
|
;;
|
|
--*)
|
|
echo "unknown option: $arg" >&2
|
|
usage "$PROG_NAME"
|
|
exit 1
|
|
;;
|
|
-*)
|
|
echo "unknown option: $arg" >&2
|
|
usage "$PROG_NAME"
|
|
exit 1
|
|
;;
|
|
*)
|
|
if [ -z "$MESSAGE" ]; then MESSAGE="$arg"; else MESSAGE="$MESSAGE $arg"; fi
|
|
i=$((i+1))
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# --clear / --reset: delete the log file and exit.
|
|
if [ "$CLEAR_MODE" = "1" ]; then
|
|
if [ -f "$LOG_PATH" ]; then
|
|
rm -f "$LOG_PATH"
|
|
echo "Cleared $LOG_PATH"
|
|
else
|
|
echo "No log to clear ($LOG_PATH does not exist)"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# --tail: follow the log file in real time.
|
|
if [ "$TAIL_MODE" = "1" ]; then
|
|
if [ -f "$LOG_PATH" ]; then
|
|
tail -f "$LOG_PATH"
|
|
else
|
|
echo "No log to tail ($LOG_PATH does not exist)" >&2
|
|
exit 1
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# --init: create local .aish/ with default config + prompt, then exit.
|
|
if [ "$INIT_MODE" = "1" ]; then
|
|
mkdir -p "$LOCAL_DIR" || { echo "error: failed to create $LOCAL_DIR" >&2; exit 1; }
|
|
chmod 700 "$LOCAL_DIR" 2>/dev/null || true
|
|
# Temporarily point paths to the local dir for ensure_data_dir
|
|
DIR="$LOCAL_DIR"
|
|
CONFIG_PATH="$DIR/$CONFIG_FILENAME"
|
|
PROMPT_PATH="$DIR/$PROMPT_FILENAME"
|
|
LOG_PATH="$DIR/$LOG_FILENAME"
|
|
SOCK_PATH="$DIR/$SOCK_FILENAME"
|
|
ensure_data_dir
|
|
echo "Initialized $LOCAL_DIR"
|
|
echo " Edit $CONFIG_PATH to change model, budget, etc."
|
|
echo " Edit $PROMPT_PATH to customize the agent's instructions."
|
|
exit 0
|
|
fi
|
|
|
|
# Read piped stdin if present (not a terminal). If args were also given,
|
|
# append the stdin content to the message. This enables:
|
|
# echo "explain this" | ./ai.sh
|
|
# cat error.log | ./ai.sh explain this error
|
|
# git diff | ./ai.sh -c generate a commit message
|
|
if [ ! -t 0 ]; then
|
|
STDIN_INPUT="$(cat)"
|
|
if [ -n "$STDIN_INPUT" ]; then
|
|
if [ -z "$MESSAGE" ]; then
|
|
MESSAGE="$STDIN_INPUT"
|
|
else
|
|
MESSAGE="$MESSAGE
|
|
|
|
$STDIN_INPUT"
|
|
fi
|
|
fi
|
|
fi
|
|
unset STDIN_INPUT
|
|
|
|
if [ -z "$MESSAGE" ]; then
|
|
echo "error: no message provided" >&2
|
|
usage "$0"
|
|
exit 1
|
|
fi
|
|
|
|
# --cmd mode: rewrite the message into a command-generation prompt and
|
|
# force a short, non-resuming run. The generated command is captured,
|
|
# printed to stderr in grey, and eval'd in this shell.
|
|
if [ "$CMD_MODE" = "1" ]; then
|
|
MESSAGE="Respond with only the shell command to accomplish this task. No explanation, no markdown, no backticks — just the raw command ready to paste into a terminal: $MESSAGE"
|
|
RESUME=0
|
|
MAX_STEPS_OVERRIDE=3
|
|
HAS_MAX_STEPS=1
|
|
fi
|
|
|
|
[ "$IS_AUTONOMOUS" = "1" ] && MODE="autonomous" || MODE="standalone"
|
|
|
|
# Ensure data dir exists (creates ~/.aish/ in global mode; no-op dir-wise
|
|
# in local mode since .aish/ already exists). Ensures config + prompt files.
|
|
if ! ensure_data_dir; then
|
|
echo "error: failed to create data directory ($DIR)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Get API key — priority: --api-key flag > PPQ_API_KEY env > DEFAULT_API_KEY
|
|
API_KEY="$API_KEY_OVERRIDE"
|
|
if [ -z "$API_KEY" ]; then
|
|
API_KEY="${PPQ_API_KEY:-}"
|
|
fi
|
|
if [ -z "$API_KEY" ]; then
|
|
API_KEY="$DEFAULT_API_KEY"
|
|
fi
|
|
if [ -z "$API_KEY" ]; then
|
|
echo "error: no API key provided. Use --api-key or set PPQ_API_KEY" >&2
|
|
echo " (.aish/ has been set up — set the key and try again)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Load config
|
|
config_load
|
|
|
|
# Load system prompt
|
|
SYSTEM_PROMPT="$(load_system_prompt)"
|
|
|
|
# Resume or fresh
|
|
if [ "$RESUME" = "1" ]; then
|
|
resume_from_log "$SYSTEM_PROMPT"
|
|
msg_count="$(wc -l < "$MESSAGES_FILE")"
|
|
if [ "$msg_count" -gt 1 ]; then
|
|
status "[loaded $LOG_PATH — $msg_count messages, \$$RESUME_CUM_COST spent]"
|
|
fi
|
|
seed_step="$RESUME_LAST_STEP"
|
|
seed_cost="$RESUME_CUM_COST"
|
|
else
|
|
conv_init "$SYSTEM_PROMPT"
|
|
seed_step=0
|
|
seed_cost=0.0
|
|
fi
|
|
|
|
# Append the user's message
|
|
conv_append_user "$MESSAGE"
|
|
log_user_input "$MESSAGE"
|
|
|
|
# Initialize budget, seeded from resume
|
|
budget_init "$CFG_MAX_COST" "$CFG_MAX_STEPS" "$CFG_TIMEOUT" "$seed_cost" "$seed_step"
|
|
|
|
# Mode banner
|
|
if [ "$MODE" = "autonomous" ]; then
|
|
if [ "$(printf '%s' "$MESSAGE" | wc -c)" -gt 80 ]; then
|
|
status "[autonomous mode — goal: (long goal)]"
|
|
else
|
|
status "[autonomous mode — goal: $MESSAGE]"
|
|
fi
|
|
fi
|
|
|
|
# Run the agent loop. In --cmd mode, capture stdout (the generated command)
|
|
# while letting status messages go to stderr.
|
|
if [ "$CMD_MODE" = "1" ]; then
|
|
CMD_OUT="$(agent_loop_run 2>&1)"
|
|
loop_rc=$?
|
|
# Extract the last non-empty line as the command (status lines start with [).
|
|
CMD_GEN="$(printf '%s\n' "$CMD_OUT" | grep -v '^\[' | sed '/^$/d' | tail -n 1)"
|
|
if [ -z "$CMD_GEN" ]; then
|
|
echo "aishc: no command returned from LLM" >&2
|
|
rm -f "$MESSAGES_FILE"
|
|
exit 1
|
|
fi
|
|
printf '\033[2m$ %s\033[0m\n' "$CMD_GEN" >&2
|
|
eval "$CMD_GEN"
|
|
eval_rc=$?
|
|
rm -f "$MESSAGES_FILE"
|
|
exit $eval_rc
|
|
fi
|
|
|
|
agent_loop_run
|
|
loop_rc=$?
|
|
|
|
# Print summary
|
|
status ""
|
|
status "[finished — $BUDGET_STEP steps, \$$BUDGET_COST spent]"
|
|
|
|
# Cleanup
|
|
rm -f "$MESSAGES_FILE"
|
|
|
|
exit $loop_rc
|