#include "config.h" #include #include #include #include #include #include #include #include #include #include #include "compact.h" #include "dotagents.h" #include "tools.h" #define DEFAULT_COMPACT_THRESHOLD 0.75 #define DEFAULT_CLEAR_THRESHOLD 0.5 #define DEFAULT_MAX_RETRIES 3UL #define DEFAULT_SKILL_INDEX_CAP 50UL #define DEFAULT_SHELL_TIMEOUT_SECS 120UL #define DEFAULT_MAX_OUTPUT_CHARS 16000UL #define DEFAULT_MAX_FILE_BYTES 2000000UL /* The default system preamble. The "Available tools" section is generated * from the tool registry (tools.c) so it can't drift from the actual * tool set. */ static const char *PREAMBLE_HEAD = "You are an agent operating inside a terminal. You accomplish tasks by " "calling tools.\n" "\n"; static const char *PREAMBLE_TAIL = "\n" "Guidelines:\n" "- Paths may be relative to the working directory or absolute.\n" "- Inspect before you modify: use read_file, or run_shell with ls / " "rg / grep, to understand existing state.\n" "- Prefer small, verifiable steps. After making changes, verify them " "(e.g. run the build or tests with run_shell).\n" "- Keep run_shell commands safe and scoped to the task; avoid " "destructive commands unless explicitly asked.\n" "- When a task is complete, stop calling tools and reply with a short " "summary of what you did."; char *default_preamble(void) { xbuf b; xbuf_init(&b); xbuf_put(&b, PREAMBLE_HEAD); char *tools = tool_preamble_list(); xbuf_put(&b, tools); free(tools); xbuf_put(&b, PREAMBLE_TAIL); char *out = xbuf_steal(&b); xbuf_free(&b); return out; } /* ------------------------------------------------------------------ */ /* Providers */ /* ------------------------------------------------------------------ */ provider_kind provider_parse(const char *raw, char **err) { xbuf l; xbuf_init(&l); for (const char *p = raw; *p; p++) xbuf_push(&l, (char)tolower((unsigned char)*p)); l.data[l.len] = '\0'; const char *s = l.data; if (strcmp(s, "openai") == 0 || strcmp(s, "openai-compatible") == 0 || strcmp(s, "openai_compatible") == 0) { xbuf_free(&l); return PROV_OPENAI; } if (strcmp(s, "anthropic") == 0 || strcmp(s, "claude") == 0) { xbuf_free(&l); return PROV_ANTHROPIC; } if (strcmp(s, "ollama") == 0) { xbuf_free(&l); return PROV_OLLAMA; } xbuf b; xbuf_init(&b); xbuf_fmt(&b, "unknown provider `%s` (expected openai, anthropic, or " "ollama)", raw); xbuf_free(&l); char *r = xbuf_steal(&b); if (err) *err = r; else free(r); return PROV_OPENAI; } const char *provider_name(provider_kind k) { switch (k) { case PROV_OPENAI: return "openai"; case PROV_ANTHROPIC: return "anthropic"; case PROV_OLLAMA: return "ollama"; } return "openai"; } static const char *provider_debug(provider_kind k) { switch (k) { case PROV_OPENAI: return "OpenAI"; case PROV_ANTHROPIC: return "Anthropic"; case PROV_OLLAMA: return "Ollama"; } return "OpenAI"; } const char *provider_default_model(provider_kind k) { switch (k) { case PROV_OPENAI: return "gpt-5.2"; case PROV_ANTHROPIC: return "claude-sonnet-4-6"; case PROV_OLLAMA: return "llama3.2"; } return "gpt-5.2"; } /* ------------------------------------------------------------------ */ /* CLI parsing */ /* ------------------------------------------------------------------ */ static void print_usage(FILE *f) { fprintf( f, "llsh %s\n" "llsh — a terminal LLM agent harness built on rig\n" "\n" "Usage: llsh [OPTIONS] [PROMPT]\n" "\n" "Arguments:\n" " [PROMPT] One-shot prompt. If omitted (and stdin is a TTY), " "launches the interactive REPL.\n" "\n" "Options:\n" " --model Model name, e.g. `gpt-5.2`, " "`claude-sonnet-4-6`, `llama3.2`; or a `.agents/models.json` " "preset name (which also brings that preset's " "provider/base_url/api_key). Overrides the preset's `model`; " "defaults per provider when unset\n" " --provider Model provider: `openai`, " "`anthropic`, or `ollama`. `openai` also works against any " "OpenAI-compatible endpoint via `--base-url`\n" " --base-url Base URL override. For `openai` " "this targets any OpenAI-compatible endpoint (LM Studio, vLLM, " "Ollama's /v1, llama.cpp, ...); for `ollama` this is the Ollama " "server address (default http://localhost:11434)\n" " --api-key API key. Overrides the preset's " "`api_key`\n" " -C, --cwd Working directory the agent's file and shell " "tools operate in\n" " --preamble System preamble for the agent\n" " --max-turns Maximum model calls (including " "tool-call continuations) per user turn. `0` (the default) is " "unbounded\n" " --max-retries Number of times a transient " "provider failure (timeout, 429, 5xx) is retried with exponential " "backoff before the turn fails. `0` disables retry\n" " --context-limit Model context window in " "tokens. `0` (the default) disables context compaction; a window " "reported by the provider's endpoint is adopted when available, " "otherwise this value is used\n" " --compact-threshold Fraction of " "`context_limit` at which earlier context is summarized. `0` " "disables the tier; otherwise the value must be in `(0, 1]`\n" " --clear-threshold Fraction of " "`context_limit` at which stale tool results are cleared. `0` " "disables the tier; otherwise the value must be in `(0, 1]`\n" " --no-rubric-compaction Disable the SelfCompact rubric " "probe that compacts the trajectory at a judged-safe point " "(independent of the token thresholds above)\n" " --rubric-max-summaries Cap on rubric-triggered " "compactions per session. The default is `1` (the paper's cap); " "`0` means unlimited\n" " --verbose Verbose, structured logging to stderr (one JSON " "object per event: turn lifecycle, tool calls, compaction, " "retries, and errors)\n" " --debug Write the whole conversation history to " "`debug.json` (in the working directory) after every turn and " "again on exit\n" " --skill-index-cap Maximum number of " "skills listed in the preamble's skills index\n" " --shell-timeout-secs `run_shell` " "timeout in seconds (the whole process tree is killed on expiry)\n" " --max-output-chars Cap on tool/shell " "output text, in characters (longer is truncated)\n" " --max-file-bytes Cap on the size of a " "file `read_file` will read, in bytes\n" " --resume Resume a session saved by `/save`: restore " "its conversation, model, preset, sub-agent, and working " "directory. The saved model/config values are authoritative, so " "`--model` / `--provider` / `--base-url` / `--api-key` are " "ignored when this is set\n" " --allow-mcp Trust and start MCP servers from .agents/mcp.json\n" " --shell-allow Allow this run_shell command (repeatable)\n" " --shell-deny Deny this run_shell command (repeatable)\n" " --confirm-destructive Prompt before recognized destructive shell commands\n" " --no-confirm-destructive Disable the destructive-command prompt\n" " -h, --help Print help\n" " -V, --version Print version\n", LLSH_VERSION); } static int parse_ulong(const char *s, unsigned long *out, char **err, const char *flag) { /* strtoull silently accepts a leading '-' and wraps on overflow; reject * both explicitly so `--max-turns -1` does not become ULONG_MAX. */ errno = 0; char *endp = NULL; unsigned long v = strtoull(s, &endp, 10); if (s[0] == '-' || endp == s || *endp != '\0' || errno == ERANGE) { if (err) { xbuf b; xbuf_init(&b); xbuf_fmt(&b, "invalid value for %s: `%s`", flag, s); *err = xbuf_steal(&b); } return -1; } *out = v; return 0; } static int parse_fraction_arg(const char *s, double *out, char **err, const char *flag) { errno = 0; char *endp = NULL; double value = strtod(s, &endp); if (endp == s || *endp != '\0' || errno == ERANGE || !isfinite(value) || value < 0.0 || value > 1.0) { if (err) *err = xstrfmt("invalid value for %s: `%s` (expected 0 or a finite value in (0, 1])", flag, s); return -1; } *out = value; return 0; } int cli_parse(int argc, char **argv, cli *out, char **err) { memset(out, 0, sizeof *out); sl_init(&out->shell_allowlist); sl_init(&out->shell_denylist); enum { OPT_MODEL = 256, OPT_PROVIDER, OPT_BASE_URL, OPT_API_KEY, OPT_CWD, OPT_PREAMBLE, OPT_MAX_TURNS, OPT_MAX_RETRIES, OPT_CONTEXT_LIMIT, OPT_COMPACT_THRESHOLD, OPT_CLEAR_THRESHOLD, OPT_VERBOSE, OPT_DEBUG, OPT_NO_RUBRIC, OPT_RUBRIC_MAX, OPT_SKILL_INDEX_CAP, OPT_SHELL_TIMEOUT, OPT_MAX_OUTPUT, OPT_MAX_FILE, OPT_RESUME, OPT_ALLOW_MCP, OPT_SHELL_ALLOW, OPT_SHELL_DENY, OPT_CONFIRM_DESTRUCTIVE, OPT_NO_CONFIRM_DESTRUCTIVE, }; static const struct option long_opts[] = { {"model", required_argument, 0, OPT_MODEL}, {"provider", required_argument, 0, OPT_PROVIDER}, {"base-url", required_argument, 0, OPT_BASE_URL}, {"api-key", required_argument, 0, OPT_API_KEY}, {"cwd", required_argument, 0, OPT_CWD}, {"preamble", required_argument, 0, OPT_PREAMBLE}, {"max-turns", required_argument, 0, OPT_MAX_TURNS}, {"max-retries", required_argument, 0, OPT_MAX_RETRIES}, {"context-limit", required_argument, 0, OPT_CONTEXT_LIMIT}, {"compact-threshold", required_argument, 0, OPT_COMPACT_THRESHOLD}, {"clear-threshold", required_argument, 0, OPT_CLEAR_THRESHOLD}, {"verbose", no_argument, 0, OPT_VERBOSE}, {"debug", no_argument, 0, OPT_DEBUG}, {"no-rubric-compaction", no_argument, 0, OPT_NO_RUBRIC}, {"rubric-max-summaries", required_argument, 0, OPT_RUBRIC_MAX}, {"skill-index-cap", required_argument, 0, OPT_SKILL_INDEX_CAP}, {"shell-timeout-secs", required_argument, 0, OPT_SHELL_TIMEOUT}, {"max-output-chars", required_argument, 0, OPT_MAX_OUTPUT}, {"max-file-bytes", required_argument, 0, OPT_MAX_FILE}, {"resume", required_argument, 0, OPT_RESUME}, {"allow-mcp", no_argument, 0, OPT_ALLOW_MCP}, {"shell-allow", required_argument, 0, OPT_SHELL_ALLOW}, {"shell-deny", required_argument, 0, OPT_SHELL_DENY}, {"confirm-destructive", no_argument, 0, OPT_CONFIRM_DESTRUCTIVE}, {"no-confirm-destructive", no_argument, 0, OPT_NO_CONFIRM_DESTRUCTIVE}, {"help", no_argument, 0, 'h'}, {"version", no_argument, 0, 'V'}, {0, 0, 0, 0}, }; int c; int prompt_set = 0; optind = 1; while ((c = getopt_long(argc, argv, "C:hV", long_opts, NULL)) != -1) { switch (c) { case OPT_MODEL: out->model = xstrdup(optarg); break; case OPT_PROVIDER: out->provider = xstrdup(optarg); break; case OPT_BASE_URL: out->base_url = xstrdup(optarg); break; case OPT_API_KEY: out->api_key = xstrdup(optarg); break; case OPT_CWD: case 'C': out->cwd = xstrdup(optarg); break; case OPT_PREAMBLE: out->preamble = xstrdup(optarg); break; case OPT_MAX_TURNS: if (parse_ulong(optarg, &out->max_turns, err, "--max-turns") != 0) return -1; out->has_max_turns = true; break; case OPT_MAX_RETRIES: if (parse_ulong(optarg, &out->max_retries, err, "--max-retries") != 0) return -1; out->has_max_retries = true; break; case OPT_CONTEXT_LIMIT: if (parse_ulong(optarg, &out->context_limit, err, "--context-limit") != 0) return -1; out->has_context_limit = true; break; case OPT_COMPACT_THRESHOLD: if (parse_fraction_arg(optarg, &out->compact_threshold, err, "--compact-threshold") != 0) return -1; out->has_compact_threshold = true; break; case OPT_CLEAR_THRESHOLD: if (parse_fraction_arg(optarg, &out->clear_threshold, err, "--clear-threshold") != 0) return -1; out->has_clear_threshold = true; break; case OPT_VERBOSE: out->verbose = true; break; case OPT_DEBUG: out->debug = true; break; case OPT_NO_RUBRIC: out->no_rubric = true; break; case OPT_RUBRIC_MAX: if (parse_ulong(optarg, &out->rubric_max_summaries, err, "--rubric-max-summaries") != 0) return -1; out->has_rubric_max_summaries = true; break; case OPT_SKILL_INDEX_CAP: if (parse_ulong(optarg, &out->skill_index_cap, err, "--skill-index-cap") != 0) return -1; out->has_skill_index_cap = true; break; case OPT_SHELL_TIMEOUT: if (parse_ulong(optarg, &out->shell_timeout_secs, err, "--shell-timeout-secs") != 0) return -1; out->has_shell_timeout_secs = true; break; case OPT_MAX_OUTPUT: if (parse_ulong(optarg, &out->max_output_chars, err, "--max-output-chars") != 0) return -1; out->has_max_output_chars = true; break; case OPT_MAX_FILE: if (parse_ulong(optarg, &out->max_file_bytes, err, "--max-file-bytes") != 0) return -1; out->has_max_file_bytes = true; break; case OPT_RESUME: out->resume = xstrdup(optarg); break; case OPT_ALLOW_MCP: out->allow_mcp = true; break; case OPT_SHELL_ALLOW: if (!optarg[0]) { if (err) *err = xstrdup("--shell-allow requires a non-empty command"); return -1; } sl_push_copy(&out->shell_allowlist, optarg); break; case OPT_SHELL_DENY: if (!optarg[0]) { if (err) *err = xstrdup("--shell-deny requires a non-empty command"); return -1; } sl_push_copy(&out->shell_denylist, optarg); break; case OPT_CONFIRM_DESTRUCTIVE: out->confirm_destructive = true; out->has_confirm_destructive = true; break; case OPT_NO_CONFIRM_DESTRUCTIVE: out->confirm_destructive = false; out->has_confirm_destructive = true; break; case 'h': print_usage(stdout); return CLI_HELP; case 'V': fprintf(stdout, "llsh %s\n", LLSH_VERSION); return CLI_HELP; case '?': print_usage(stderr); if (err) *err = xstrdup("try --help"); return -1; default: /* getopt_long returns '?' for unknown options; anything else * here is a short option we did not register. */ print_usage(stderr); if (err) *err = xstrdup("try --help"); return -1; } } for (int i = optind; i < argc; i++) { if (prompt_set) { xbuf b; xbuf_init(&b); xbuf_fmt(&b, "unexpected argument `%s`", argv[i]); char *r = xbuf_steal(&b); if (err) *err = r; else free(r); return -1; } out->prompt = xstrdup(argv[i]); prompt_set = 1; } return 0; } void cli_free(cli *c) { free(c->prompt); free(c->model); free(c->provider); free(c->base_url); free(c->api_key); free(c->cwd); free(c->preamble); free(c->resume); sl_free(&c->shell_allowlist); sl_free(&c->shell_denylist); } /* ------------------------------------------------------------------ */ /* Merge + validation */ /* ------------------------------------------------------------------ */ static int validate_fraction(const char *name, double value, char **err) { if (value == 0.0) return 0; if (isnan(value) || isinf(value) || value <= 0.0 || value > 1.0) { if (err) { xbuf b; xbuf_init(&b); const char *rep; if (isnan(value)) rep = "NaN"; else if (value > 0) rep = "inf"; else rep = "-inf"; xbuf_fmt(&b, "%s must be 0 or in (0, 1], got %s", name, rep); *err = xbuf_steal(&b); } return -1; } return 0; } static int validate_compaction(const config *c, char **err) { if (validate_fraction("compact_threshold", c->compact_threshold, err) != 0) return -1; if (validate_fraction("clear_threshold", c->clear_threshold, err) != 0) return -1; if (c->context_limit > 0 && c->compact_threshold > 0.0 && c->clear_threshold > 0.0 && c->clear_threshold >= c->compact_threshold) { if (err) { char *ct = dtoa_dup(c->clear_threshold); char *pt = dtoa_dup(c->compact_threshold); xbuf b; xbuf_init(&b); xbuf_fmt(&b, "clear_threshold (%s) must be less than " "compact_threshold (%s)", ct, pt); *err = xbuf_steal(&b); free(ct); free(pt); } return -1; } return 0; } int config_require_key_or_base(const config *c, char **err) { if (c->provider != PROV_OLLAMA && !c->base_url && !c->api_key) { if (err) { xbuf b; xbuf_init(&b); xbuf_fmt(&b, "no API key for provider `%s`: set `api_key` in " "`.agents/models.json` or pass --api-key", provider_debug(c->provider)); *err = xbuf_steal(&b); } return -1; } return 0; } void config_free(config *c) { free(c->model); free(c->base_url); free(c->api_key); free(c->preamble); sl_free(&c->shell_allowlist); sl_free(&c->shell_denylist); } int config_merge(const cli *cli, config *out, char **err) { memset(out, 0, sizeof *out); sl_init(&out->shell_allowlist); sl_init(&out->shell_denylist); out->confirm_destructive = cli->confirm_destructive; for (size_t i = 0; i < cli->shell_allowlist.len; i++) sl_push_copy(&out->shell_allowlist, cli->shell_allowlist.items[i]); for (size_t i = 0; i < cli->shell_denylist.len; i++) sl_push_copy(&out->shell_denylist, cli->shell_denylist.items[i]); out->provider = provider_parse(cli->provider ? cli->provider : "openai", err); if (err && *err) return -1; out->model = xstrdup(cli->model ? cli->model : provider_default_model(out->provider)); out->base_url = cli->base_url ? xstrdup(cli->base_url) : NULL; out->api_key = cli->api_key ? xstrdup(cli->api_key) : NULL; out->preamble = cli->preamble ? xstrdup(cli->preamble) : default_preamble(); out->max_turns = cli->has_max_turns ? cli->max_turns : 0; out->max_retries = cli->has_max_retries ? cli->max_retries : DEFAULT_MAX_RETRIES; out->context_limit = cli->has_context_limit ? cli->context_limit : 0; out->compact_threshold = cli->has_compact_threshold ? cli->compact_threshold : DEFAULT_COMPACT_THRESHOLD; out->clear_threshold = cli->has_clear_threshold ? cli->clear_threshold : DEFAULT_CLEAR_THRESHOLD; out->rubric_enabled = !cli->no_rubric; out->rubric_max_summaries = cli->has_rubric_max_summaries ? cli->rubric_max_summaries : RUBRIC_DEFAULT_MAX_SUMMARIES; out->verbose = cli->verbose; out->skill_index_cap = cli->has_skill_index_cap ? cli->skill_index_cap : DEFAULT_SKILL_INDEX_CAP; if (out->skill_index_cap < 1) out->skill_index_cap = 1; out->shell_timeout_secs = cli->has_shell_timeout_secs ? cli->shell_timeout_secs : DEFAULT_SHELL_TIMEOUT_SECS; if (out->shell_timeout_secs < 1) out->shell_timeout_secs = 1; out->max_output_chars = cli->has_max_output_chars ? cli->max_output_chars : DEFAULT_MAX_OUTPUT_CHARS; if (out->max_output_chars < 1) out->max_output_chars = 1; out->max_file_bytes = cli->has_max_file_bytes ? cli->max_file_bytes : DEFAULT_MAX_FILE_BYTES; if (out->max_file_bytes < 1) out->max_file_bytes = 1; if (validate_compaction(out, err) != 0) return -1; /* The api_key/base_url requirement is checked by the caller AFTER a * models.json preset may have supplied them (precedence: CLI flag > * models.json preset > provider defaults). */ return 0; } int config_from_cli(const cli *cli, config *out, char **err) { const char *cwd = cli->cwd ? cli->cwd : "."; if (config_merge(cli, out, err) != 0) { config_free(out); return -1; } char *cwd_exp = expand_tilde(cwd); if (chdir(cwd_exp) != 0) { if (err) { xbuf b; xbuf_init(&b); xbuf_fmt(&b, "failed to enter working directory `%s`: %s", cwd_exp, strerror(errno)); *err = xbuf_steal(&b); } free(cwd_exp); config_free(out); return -1; } free(cwd_exp); return 0; } /* ------------------------------------------------------------------ */ /* models.json presets */ /* ------------------------------------------------------------------ */ char *config_apply_models_preset(config *cfg, const models_file *models, const cli *cli, char **err) { const char *name = models->default_name; if (!name || !*name) { if (err) *err = xstrdup("models.json declares no `default` preset"); return NULL; } const model_preset *preset = models_file_get(models, name); if (!preset) { if (err) { xbuf b; xbuf_init(&b); xbuf_put(&b, "models.json default preset `"); xbuf_put(&b, name); xbuf_put(&b, "` is not in presets ("); for (size_t i = 0; i < models->preset_count; i++) { if (i) xbuf_put(&b, ", "); xbuf_put(&b, models->presets[i].name ? models->presets[i].name : ""); } xbuf_put(&b, ")"); *err = xbuf_steal(&b); } return NULL; } if (!cli->provider && preset->provider) { cfg->provider = provider_parse(preset->provider, err); if (err && *err) return NULL; } if (!cli->model && preset->model) { free(cfg->model); cfg->model = xstrdup(preset->model); } if (!cli->base_url && preset->base_url) { free(cfg->base_url); cfg->base_url = xstrdup(preset->base_url); } if (!cli->api_key && preset->api_key) { free(cfg->api_key); cfg->api_key = xstrdup(preset->api_key); } if (!cli->has_max_turns && preset->has_max_turns && preset->max_turns > 0) cfg->max_turns = preset->max_turns; if (!cli->has_context_limit && preset->has_context_limit) cfg->context_limit = preset->context_limit; if (cfg->provider != PROV_OLLAMA && !cfg->base_url && !cfg->api_key) { if (err) { xbuf b; xbuf_init(&b); xbuf_fmt(&b, "no API key for provider `%s` after applying preset " "`%s`: set `api_key` in the preset/config file or " "pass --api-key", provider_debug(cfg->provider), name); *err = xbuf_steal(&b); } return NULL; } if (validate_compaction(cfg, err) != 0) return NULL; return xstrdup(name); }