10245 lines
384 KiB
C
10245 lines
384 KiB
C
#include "ds4.h"
|
||
#include "ds4_distributed.h"
|
||
#include "ds4_help.h"
|
||
#include "ds4_kvstore.h"
|
||
#include "ds4_web.h"
|
||
#include "linenoise.h"
|
||
|
||
#include <errno.h>
|
||
#include <ctype.h>
|
||
#include <dirent.h>
|
||
#include <fnmatch.h>
|
||
#include <fcntl.h>
|
||
#include <limits.h>
|
||
#include <math.h>
|
||
#include <poll.h>
|
||
#include <pthread.h>
|
||
#include <regex.h>
|
||
#include <signal.h>
|
||
#include <stdarg.h>
|
||
#include <stdbool.h>
|
||
#include <stdint.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <strings.h>
|
||
#include <sys/ioctl.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/wait.h>
|
||
#include <time.h>
|
||
#include <unistd.h>
|
||
|
||
/* This is intentionally not in linenoise.h, but it is part of the existing
|
||
* multiplexed editor implementation. The agent uses it only to restore text
|
||
* after Enter is pressed while the model is still busy. */
|
||
int linenoiseEditInsert(struct linenoiseState *l, const char *c, size_t clen);
|
||
|
||
static int set_nonblock(int fd, bool on, int *old_flags);
|
||
static bool agent_parse_bool_default(const char *s, bool def);
|
||
|
||
/* ============================================================================
|
||
* Configuration, Worker State, And Streaming Types
|
||
* ============================================================================
|
||
*
|
||
* The agent is intentionally a single process: the UI thread owns terminal
|
||
* input/output, while the worker thread owns the live DS4 session and KV state.
|
||
* These types define the shared state and the small streaming state machines
|
||
* used to render sampled assistant text and DSML tool calls as they arrive.
|
||
*/
|
||
|
||
typedef struct {
|
||
const char *prompt;
|
||
const char *system;
|
||
const char *trace_path;
|
||
int n_predict;
|
||
int ctx_size;
|
||
float temperature;
|
||
float top_p;
|
||
float min_p;
|
||
uint64_t seed;
|
||
ds4_think_mode think_mode;
|
||
} agent_generation_options;
|
||
|
||
typedef struct {
|
||
ds4_engine_options engine;
|
||
agent_generation_options gen;
|
||
const char *chdir_path;
|
||
bool non_interactive;
|
||
} agent_config;
|
||
|
||
typedef enum {
|
||
AGENT_WORKER_IDLE,
|
||
AGENT_WORKER_PREFILL,
|
||
AGENT_WORKER_GENERATING,
|
||
AGENT_WORKER_COMPACTING,
|
||
AGENT_WORKER_DRAINING,
|
||
AGENT_WORKER_SAVING,
|
||
AGENT_WORKER_ERROR,
|
||
AGENT_WORKER_STOPPED,
|
||
} agent_worker_state;
|
||
|
||
typedef struct {
|
||
agent_worker_state state;
|
||
int prefill_done;
|
||
int prefill_total;
|
||
unsigned prefill_label;
|
||
double prefill_tps;
|
||
int generated;
|
||
double gen_tps;
|
||
bool greedy_sampling;
|
||
int ctx_used;
|
||
int ctx_size;
|
||
int power_percent;
|
||
char error[256];
|
||
} agent_status;
|
||
|
||
typedef struct agent_bash_job agent_bash_job;
|
||
|
||
typedef struct {
|
||
ds4_engine *engine;
|
||
agent_config *cfg;
|
||
ds4_session *session;
|
||
ds4_tokens transcript;
|
||
char *cache_dir;
|
||
char *sysprompt_path;
|
||
char session_sha[41];
|
||
char *session_title;
|
||
uint64_t session_created_at;
|
||
char *legacy_session_path_to_delete;
|
||
bool user_activity;
|
||
bool session_dirty;
|
||
pthread_t thread;
|
||
pthread_mutex_t mu;
|
||
pthread_cond_t cond;
|
||
int wake_fd[2];
|
||
FILE *trace;
|
||
bool wake_pending;
|
||
bool stop;
|
||
bool interrupt;
|
||
bool initialized;
|
||
bool save_requested;
|
||
bool compact_requested;
|
||
bool power_requested;
|
||
int requested_power;
|
||
int progress_base;
|
||
double progress_started_at;
|
||
int last_system_prompt_reminder_at;
|
||
char *cmd_text;
|
||
agent_status status;
|
||
char *out;
|
||
size_t out_len;
|
||
size_t out_cap;
|
||
ds4_web *web;
|
||
bool web_approval_pending;
|
||
bool web_approval_answered;
|
||
bool web_approval_result;
|
||
char web_approval_message[256];
|
||
char web_approval_error[160];
|
||
bool queued_user_drain_pending;
|
||
bool queued_user_drain_answered;
|
||
char *queued_user_drain_text;
|
||
bool datetime_context_injected;
|
||
char more_path[PATH_MAX];
|
||
int more_next_line;
|
||
bool more_bare;
|
||
bool more_valid;
|
||
agent_bash_job *bash_jobs;
|
||
int next_bash_job_id;
|
||
bool raw_mode_needs_restore;
|
||
} agent_worker;
|
||
|
||
static unsigned agent_next_prefill_label(void);
|
||
|
||
typedef struct agent_tail_capture {
|
||
char *buf;
|
||
size_t cap;
|
||
size_t start;
|
||
size_t len;
|
||
size_t total;
|
||
} agent_tail_capture;
|
||
|
||
typedef enum {
|
||
AGENT_MD_PENDING_NONE,
|
||
AGENT_MD_PENDING_STAR,
|
||
AGENT_MD_PENDING_BACKTICK,
|
||
} agent_markdown_pending;
|
||
|
||
typedef struct agent_syntax agent_syntax;
|
||
|
||
typedef struct {
|
||
ds4_engine *engine;
|
||
agent_worker *worker;
|
||
bool format_thinking;
|
||
bool format_markdown;
|
||
bool in_think;
|
||
bool color_open;
|
||
bool use_color;
|
||
bool last_output_newline;
|
||
bool wrote_visible_output;
|
||
bool md_bold;
|
||
bool md_italic;
|
||
bool md_inline_code;
|
||
bool md_code_block;
|
||
bool md_fence_info;
|
||
bool md_code_line_start;
|
||
bool md_code_in_ml_comment;
|
||
bool md_syntax_silent;
|
||
bool md_syntax_has_highlight;
|
||
agent_markdown_pending md_pending;
|
||
size_t md_pending_len;
|
||
const agent_syntax *md_syntax;
|
||
char md_fence_lang[32];
|
||
size_t md_fence_lang_len;
|
||
const char *md_code_line_prefix;
|
||
const char *md_code_line_prefix_color;
|
||
bool md_code_highlight_upto;
|
||
char *md_code_line;
|
||
size_t md_code_line_len;
|
||
size_t md_code_line_cap;
|
||
char pending[16];
|
||
size_t pending_len;
|
||
char utf8_pending[4];
|
||
size_t utf8_pending_len;
|
||
size_t utf8_pending_need;
|
||
agent_tail_capture *capture;
|
||
} agent_token_renderer;
|
||
|
||
typedef struct {
|
||
char *name;
|
||
char *value;
|
||
bool is_string;
|
||
} agent_tool_arg;
|
||
|
||
typedef struct {
|
||
char *name;
|
||
agent_tool_arg *args;
|
||
int argc;
|
||
int argcap;
|
||
} agent_tool_call;
|
||
|
||
typedef struct {
|
||
agent_tool_call *v;
|
||
int len;
|
||
int cap;
|
||
} agent_tool_calls;
|
||
|
||
typedef enum {
|
||
AGENT_DSML_SEARCH,
|
||
AGENT_DSML_STRUCTURAL,
|
||
AGENT_DSML_PARAM_VALUE,
|
||
AGENT_DSML_DONE,
|
||
AGENT_DSML_ERROR,
|
||
} agent_dsml_state;
|
||
|
||
typedef struct {
|
||
agent_dsml_state state;
|
||
char search_tail[64];
|
||
size_t search_len;
|
||
char *raw;
|
||
size_t raw_len;
|
||
size_t raw_cap;
|
||
size_t parse_pos;
|
||
agent_tool_call current;
|
||
char *param_name;
|
||
bool param_is_string;
|
||
size_t param_value_start;
|
||
bool param_close_prefix;
|
||
agent_tool_calls calls;
|
||
char error[160];
|
||
} agent_dsml_parser;
|
||
|
||
typedef enum {
|
||
AGENT_TOOL_PARAM_NORMAL,
|
||
AGENT_TOOL_PARAM_PATH,
|
||
AGENT_TOOL_PARAM_OFFSET,
|
||
AGENT_TOOL_PARAM_CONTENT,
|
||
AGENT_TOOL_PARAM_DIFF_OLD,
|
||
AGENT_TOOL_PARAM_DIFF_NEW,
|
||
AGENT_TOOL_PARAM_BASH_COMMAND,
|
||
} agent_tool_param_kind;
|
||
|
||
typedef struct {
|
||
bool active;
|
||
bool tool_announced;
|
||
bool param_active;
|
||
bool at_line_start;
|
||
bool last_output_newline;
|
||
agent_tool_param_kind param_kind;
|
||
char tool_name[64];
|
||
char param_name[64];
|
||
char param_end_tail[64];
|
||
size_t param_end_len;
|
||
bool read_style;
|
||
bool read_prefix_rendered;
|
||
bool read_line_rendered;
|
||
char read_path[512];
|
||
char read_start[32];
|
||
char read_max[32];
|
||
char read_whole[8];
|
||
char tool_path[512];
|
||
bool code_param_active;
|
||
} agent_tool_visualizer;
|
||
|
||
typedef struct {
|
||
char tail[32];
|
||
size_t len;
|
||
} agent_dsml_marker_detector;
|
||
|
||
typedef struct {
|
||
agent_token_renderer *renderer;
|
||
agent_dsml_parser *parser;
|
||
agent_tool_visualizer viz;
|
||
bool in_think;
|
||
bool dsml_active;
|
||
bool dsml_ignored;
|
||
bool replay;
|
||
char pending[16];
|
||
size_t pending_len;
|
||
char dsml_start_tail[64];
|
||
size_t dsml_start_len;
|
||
agent_dsml_marker_detector plain_dsml;
|
||
agent_dsml_marker_detector think_dsml;
|
||
bool dsml_in_think;
|
||
bool dsml_in_think_reported;
|
||
bool post_think_gap;
|
||
bool tool_preflight_error;
|
||
char tool_preflight_error_msg[256];
|
||
} agent_stream_renderer;
|
||
|
||
typedef struct {
|
||
bool active;
|
||
bool done;
|
||
} agent_edit_upto_forcer;
|
||
|
||
static volatile sig_atomic_t agent_sigint;
|
||
static agent_worker *agent_completion_worker;
|
||
|
||
static void worker_apply_pending_power(agent_worker *w);
|
||
static void agent_trace(agent_worker *w, const char *fmt, ...);
|
||
static void agent_trace_text(agent_worker *w, const char *label,
|
||
const char *text, size_t len);
|
||
static void agent_publish_system_status(agent_worker *w, const char *msg);
|
||
static int agent_web_confirm(void *privdata, const char *message,
|
||
char *err, size_t err_len);
|
||
static void agent_web_log(void *privdata, const char *message);
|
||
static bool agent_preflight_edit_old(agent_worker *w, const agent_tool_call *call,
|
||
char *err, size_t err_len);
|
||
static int agent_worker_sync_tokens(agent_worker *w, const ds4_tokens *tokens,
|
||
bool publish_progress,
|
||
char *err, size_t err_len);
|
||
|
||
/* ============================================================================
|
||
* Small Utilities And Command-Line Parsing
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void agent_sigint_handler(int sig) {
|
||
(void)sig;
|
||
agent_sigint = 1;
|
||
}
|
||
|
||
static void *xmalloc(size_t n) {
|
||
void *p = malloc(n ? n : 1);
|
||
if (!p) {
|
||
perror("ds4-agent: malloc");
|
||
exit(1);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
static char *xstrdup(const char *s) {
|
||
if (!s) s = "";
|
||
size_t n = strlen(s);
|
||
char *p = xmalloc(n + 1);
|
||
memcpy(p, s, n + 1);
|
||
return p;
|
||
}
|
||
|
||
static char *xstrndup(const char *s, size_t n) {
|
||
char *p = xmalloc(n + 1);
|
||
memcpy(p, s, n);
|
||
p[n] = '\0';
|
||
return p;
|
||
}
|
||
|
||
static void *xrealloc(void *ptr, size_t n) {
|
||
void *p = realloc(ptr, n ? n : 1);
|
||
if (!p) {
|
||
perror("ds4-agent: realloc");
|
||
exit(1);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
static void write_all(int fd, const char *p, size_t n) {
|
||
while (n) {
|
||
ssize_t wr = write(fd, p, n);
|
||
if (wr < 0) {
|
||
if (errno == EINTR) continue;
|
||
return;
|
||
}
|
||
p += wr;
|
||
n -= (size_t)wr;
|
||
}
|
||
}
|
||
|
||
typedef struct {
|
||
char *ptr;
|
||
size_t len;
|
||
size_t cap;
|
||
} agent_input_buf;
|
||
|
||
static void agent_input_buf_append(agent_input_buf *b, const char *s, size_t n) {
|
||
if (!n) return;
|
||
if (b->len + n + 1 > b->cap) {
|
||
size_t cap = b->cap ? b->cap * 2 : 4096;
|
||
while (cap < b->len + n + 1) cap *= 2;
|
||
b->ptr = xrealloc(b->ptr, cap);
|
||
b->cap = cap;
|
||
}
|
||
memcpy(b->ptr + b->len, s, n);
|
||
b->len += n;
|
||
b->ptr[b->len] = '\0';
|
||
}
|
||
|
||
static char *agent_input_buf_take(agent_input_buf *b) {
|
||
if (!b->ptr) return xstrdup("");
|
||
char *p = b->ptr;
|
||
memset(b, 0, sizeof(*b));
|
||
return p;
|
||
}
|
||
|
||
static void agent_input_buf_free(agent_input_buf *b) {
|
||
free(b->ptr);
|
||
memset(b, 0, sizeof(*b));
|
||
}
|
||
|
||
static int parse_int(const char *s, const char *opt) {
|
||
char *end = NULL;
|
||
long v = strtol(s, &end, 10);
|
||
if (s[0] == '\0' || *end != '\0' || v <= 0 || v > INT32_MAX) {
|
||
fprintf(stderr, "ds4-agent: invalid value for %s: %s\n", opt, s);
|
||
exit(2);
|
||
}
|
||
return (int)v;
|
||
}
|
||
|
||
static bool parse_power_percent(const char *arg, int *out) {
|
||
char *end = NULL;
|
||
long v = strtol(arg, &end, 10);
|
||
if (!arg[0] || *end != '\0' || v < 1 || v > 100) return false;
|
||
*out = (int)v;
|
||
return true;
|
||
}
|
||
|
||
static bool agent_slash_command_with_args(const char *cmd, const char *name) {
|
||
size_t len = strlen(name);
|
||
return !strncmp(cmd, name, len) &&
|
||
(cmd[len] == '\0' || isspace((unsigned char)cmd[len]));
|
||
}
|
||
|
||
static bool agent_slash_command_known(const char *cmd) {
|
||
return !strcmp(cmd, "/help") ||
|
||
!strcmp(cmd, "/save") ||
|
||
!strcmp(cmd, "/compact") ||
|
||
!strcmp(cmd, "/list") ||
|
||
!strcmp(cmd, "/quit") ||
|
||
!strcmp(cmd, "/exit") ||
|
||
!strcmp(cmd, "/new") ||
|
||
agent_slash_command_with_args(cmd, "/power") ||
|
||
agent_slash_command_with_args(cmd, "/switch") ||
|
||
agent_slash_command_with_args(cmd, "/del") ||
|
||
agent_slash_command_with_args(cmd, "/strip") ||
|
||
agent_slash_command_with_args(cmd, "/history");
|
||
}
|
||
|
||
static uint64_t parse_u64(const char *s, const char *opt) {
|
||
char *end = NULL;
|
||
unsigned long long v = strtoull(s, &end, 10);
|
||
if (s[0] == '\0' || *end != '\0' || v == 0) {
|
||
fprintf(stderr, "ds4-agent: invalid value for %s: %s\n", opt, s);
|
||
exit(2);
|
||
}
|
||
return (uint64_t)v;
|
||
}
|
||
|
||
static float parse_float_range(const char *s, const char *opt, float min, float max) {
|
||
char *end = NULL;
|
||
float v = strtof(s, &end);
|
||
if (s[0] == '\0' || *end != '\0' || !isfinite(v) || v < min || v > max) {
|
||
fprintf(stderr, "ds4-agent: invalid value for %s: %s\n", opt, s);
|
||
exit(2);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
static ds4_backend parse_backend(const char *s) {
|
||
if (!strcmp(s, "metal")) return DS4_BACKEND_METAL;
|
||
if (!strcmp(s, "cuda")) return DS4_BACKEND_CUDA;
|
||
if (!strcmp(s, "cpu")) return DS4_BACKEND_CPU;
|
||
fprintf(stderr, "ds4-agent: invalid backend: %s\n", s);
|
||
exit(2);
|
||
}
|
||
|
||
static ds4_backend default_backend(void) {
|
||
#ifdef DS4_NO_GPU
|
||
return DS4_BACKEND_CPU;
|
||
#elif defined(__APPLE__)
|
||
return DS4_BACKEND_METAL;
|
||
#else
|
||
return DS4_BACKEND_CUDA;
|
||
#endif
|
||
}
|
||
|
||
static double now_sec(void) {
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||
return (double)ts.tv_sec + (double)ts.tv_nsec * 1.0e-9;
|
||
}
|
||
|
||
static void usage(FILE *fp, const char *topic) {
|
||
ds4_help_print(fp, DS4_HELP_AGENT, topic);
|
||
}
|
||
|
||
static const char *need_arg(int *i, int argc, char **argv, const char *opt) {
|
||
if (*i + 1 >= argc) {
|
||
fprintf(stderr, "ds4-agent: missing value for %s\n", opt);
|
||
exit(2);
|
||
}
|
||
return argv[++(*i)];
|
||
}
|
||
|
||
static agent_config parse_options(int argc, char **argv) {
|
||
agent_config c = {
|
||
.engine = {
|
||
.model_path = "ds4flash.gguf",
|
||
.backend = default_backend(),
|
||
.mtp_draft_tokens = 1,
|
||
.mtp_margin = 3.0f,
|
||
},
|
||
.gen = {
|
||
.system = "You are a helpful coding assistant running inside ds4-agent.",
|
||
.n_predict = 50000,
|
||
.ctx_size = 100000,
|
||
.temperature = DS4_DEFAULT_TEMPERATURE,
|
||
.top_p = DS4_DEFAULT_TOP_P,
|
||
.min_p = DS4_DEFAULT_MIN_P,
|
||
.think_mode = DS4_THINK_HIGH,
|
||
},
|
||
};
|
||
|
||
bool steering_scale_set = false;
|
||
for (int i = 1; i < argc; i++) {
|
||
const char *arg = argv[i];
|
||
if (!strcmp(arg, "-h") || !strcmp(arg, "--help")) {
|
||
const char *topic = (i + 1 < argc && argv[i + 1][0] != '-') ?
|
||
argv[i + 1] : NULL;
|
||
usage(stdout, topic);
|
||
exit(0);
|
||
}
|
||
char dist_parse_err[256] = {0};
|
||
ds4_dist_cli_parse_result dist_parse =
|
||
ds4_dist_parse_cli_arg(arg,
|
||
&i,
|
||
argc,
|
||
argv,
|
||
&c.engine.distributed,
|
||
dist_parse_err,
|
||
sizeof(dist_parse_err));
|
||
if (dist_parse == DS4_DIST_CLI_ERROR) {
|
||
fprintf(stderr,
|
||
"ds4-agent: %s\n",
|
||
dist_parse_err[0] ? dist_parse_err : "invalid distributed option");
|
||
exit(2);
|
||
}
|
||
if (dist_parse == DS4_DIST_CLI_MATCHED) continue;
|
||
|
||
if (!strcmp(arg, "-p") || !strcmp(arg, "--prompt")) {
|
||
c.gen.prompt = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--non-interactive")) {
|
||
c.non_interactive = true;
|
||
} else if (!strcmp(arg, "-sys") || !strcmp(arg, "--system")) {
|
||
c.gen.system = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--trace")) {
|
||
c.gen.trace_path = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "-m") || !strcmp(arg, "--model")) {
|
||
c.engine.model_path = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--mtp")) {
|
||
c.engine.mtp_path = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--mtp-draft")) {
|
||
c.engine.mtp_draft_tokens = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
} else if (!strcmp(arg, "--mtp-margin")) {
|
||
c.engine.mtp_margin = parse_float_range(need_arg(&i, argc, argv, arg), arg, 0.0f, 1000.0f);
|
||
} else if (!strcmp(arg, "-c") || !strcmp(arg, "--ctx")) {
|
||
c.gen.ctx_size = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
} else if (!strcmp(arg, "-n") || !strcmp(arg, "--tokens")) {
|
||
c.gen.n_predict = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
} else if (!strcmp(arg, "--temp")) {
|
||
c.gen.temperature = parse_float_range(need_arg(&i, argc, argv, arg), arg, 0.0f, 100.0f);
|
||
} else if (!strcmp(arg, "--top-p")) {
|
||
c.gen.top_p = parse_float_range(need_arg(&i, argc, argv, arg), arg, 0.0f, 1.0f);
|
||
} else if (!strcmp(arg, "--min-p")) {
|
||
c.gen.min_p = parse_float_range(need_arg(&i, argc, argv, arg), arg, 0.0f, 1.0f);
|
||
} else if (!strcmp(arg, "--seed")) {
|
||
c.gen.seed = parse_u64(need_arg(&i, argc, argv, arg), arg);
|
||
} else if (!strcmp(arg, "--think")) {
|
||
c.gen.think_mode = DS4_THINK_HIGH;
|
||
} else if (!strcmp(arg, "--think-max")) {
|
||
c.gen.think_mode = DS4_THINK_MAX;
|
||
} else if (!strcmp(arg, "--nothink")) {
|
||
c.gen.think_mode = DS4_THINK_NONE;
|
||
} else if (!strcmp(arg, "--backend")) {
|
||
c.engine.backend = parse_backend(need_arg(&i, argc, argv, arg));
|
||
} else if (!strcmp(arg, "--metal")) {
|
||
c.engine.backend = DS4_BACKEND_METAL;
|
||
} else if (!strcmp(arg, "--cuda")) {
|
||
c.engine.backend = DS4_BACKEND_CUDA;
|
||
} else if (!strcmp(arg, "--cpu")) {
|
||
c.engine.backend = DS4_BACKEND_CPU;
|
||
} else if (!strcmp(arg, "-t") || !strcmp(arg, "--threads")) {
|
||
c.engine.n_threads = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
} else if (!strcmp(arg, "--chdir")) {
|
||
c.chdir_path = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--quality")) {
|
||
c.engine.quality = true;
|
||
} else if (!strcmp(arg, "--ssd-streaming")) {
|
||
c.engine.ssd_streaming = true;
|
||
} else if (!strcmp(arg, "--ssd-streaming-cold")) {
|
||
c.engine.ssd_streaming_cold = true;
|
||
} else if (!strcmp(arg, "--ssd-streaming-cache-experts")) {
|
||
uint32_t experts = 0;
|
||
uint64_t bytes = 0;
|
||
if (!ds4_parse_streaming_cache_experts_arg(
|
||
need_arg(&i, argc, argv, arg), &experts, &bytes)) {
|
||
fprintf(stderr,
|
||
"ds4-agent: --ssd-streaming-cache-experts must be a positive count or <number>GB\n");
|
||
exit(2);
|
||
}
|
||
c.engine.ssd_streaming_cache_experts = experts;
|
||
c.engine.ssd_streaming_cache_bytes = bytes;
|
||
} else if (!strcmp(arg, "--ssd-streaming-preload-experts")) {
|
||
int v = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
if (v <= 0) {
|
||
fprintf(stderr, "ds4-agent: --ssd-streaming-preload-experts must be positive\n");
|
||
exit(2);
|
||
}
|
||
c.engine.ssd_streaming_preload_experts = (uint32_t)v;
|
||
} else if (!strcmp(arg, "--simulate-used-memory")) {
|
||
if (!ds4_parse_gib_arg(need_arg(&i, argc, argv, arg),
|
||
&c.engine.simulate_used_memory_bytes)) {
|
||
fprintf(stderr,
|
||
"ds4-agent: --simulate-used-memory must be a positive GiB value, e.g. 64GB\n");
|
||
exit(2);
|
||
}
|
||
} else if (!strcmp(arg, "--prefill-chunk")) {
|
||
int v = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
if (v <= 0) {
|
||
fprintf(stderr, "ds4-agent: --prefill-chunk must be positive\n");
|
||
exit(2);
|
||
}
|
||
c.engine.prefill_chunk = (uint32_t)v;
|
||
} else if (!strcmp(arg, "--power")) {
|
||
c.engine.power_percent = parse_int(need_arg(&i, argc, argv, arg), arg);
|
||
if (c.engine.power_percent < 1 || c.engine.power_percent > 100) {
|
||
fprintf(stderr, "ds4-agent: --power must be between 1 and 100\n");
|
||
exit(2);
|
||
}
|
||
} else if (!strcmp(arg, "--warm-weights")) {
|
||
c.engine.warm_weights = true;
|
||
} else if (!strcmp(arg, "--dir-steering-file")) {
|
||
c.engine.directional_steering_file = need_arg(&i, argc, argv, arg);
|
||
} else if (!strcmp(arg, "--dir-steering-ffn")) {
|
||
c.engine.directional_steering_ffn = parse_float_range(need_arg(&i, argc, argv, arg), arg, -100.0f, 100.0f);
|
||
steering_scale_set = true;
|
||
} else if (!strcmp(arg, "--dir-steering-attn")) {
|
||
c.engine.directional_steering_attn = parse_float_range(need_arg(&i, argc, argv, arg), arg, -100.0f, 100.0f);
|
||
steering_scale_set = true;
|
||
} else {
|
||
fprintf(stderr, "ds4-agent: unknown option: %s\n", arg);
|
||
usage(stderr, NULL);
|
||
exit(2);
|
||
}
|
||
}
|
||
|
||
if (c.engine.directional_steering_file && !steering_scale_set)
|
||
c.engine.directional_steering_ffn = 1.0f;
|
||
char dist_err[256];
|
||
if (ds4_dist_prepare_engine_options(&c.engine.distributed,
|
||
&c.engine,
|
||
dist_err,
|
||
sizeof(dist_err)) != 0) {
|
||
fprintf(stderr, "ds4-agent: %s\n", dist_err);
|
||
exit(2);
|
||
}
|
||
if (c.engine.distributed.role == DS4_DISTRIBUTED_WORKER) {
|
||
fprintf(stderr, "ds4-agent: --role worker is a serving mode; start workers with ./ds4\n");
|
||
exit(2);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
static void log_context_memory(ds4_backend backend,
|
||
int ctx_size,
|
||
uint32_t prefill_chunk) {
|
||
ds4_context_memory m =
|
||
ds4_context_memory_estimate_with_prefill(backend,
|
||
ctx_size,
|
||
prefill_chunk);
|
||
fprintf(stderr,
|
||
"ds4-agent: context buffers %.2f MiB (ctx=%d, backend=%s, prefill_chunk=%u, raw_kv_rows=%u, compressed_kv_rows=%u)\n",
|
||
(double)m.total_bytes / (1024.0 * 1024.0),
|
||
ctx_size,
|
||
ds4_backend_name(backend),
|
||
m.prefill_cap,
|
||
m.raw_cap,
|
||
m.comp_cap);
|
||
}
|
||
|
||
static ds4_think_mode effective_think_mode(const agent_config *cfg) {
|
||
return ds4_think_mode_for_context(cfg->gen.think_mode, cfg->gen.ctx_size);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* System Prompt Rendering And Worker Output Queues
|
||
* ============================================================================
|
||
*/
|
||
|
||
static const char agent_tools_prompt_intro[] =
|
||
"You are a coding agent running in a local workspace. Use tools for local file and system work. "
|
||
"Avoid printing large file contents or large code blocks as answers; create or edit files with tools, "
|
||
"then summarize results briefly.\n\n"
|
||
"## Tools\n\n"
|
||
"You have access to native DSML tools. Invoke tools by writing exactly this shape:\n\n"
|
||
"<|DSML|tool_calls>\n"
|
||
"<|DSML|invoke name=\"$TOOL_NAME\">\n"
|
||
"<|DSML|parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</|DSML|parameter>\n"
|
||
"</|DSML|invoke>\n"
|
||
"</|DSML|tool_calls>\n\n"
|
||
"Tool calls are not allowed inside <think></think>; finish thinking before emitting DSML.\n\n"
|
||
"String parameters use raw text and string=\"true\". Numbers and booleans use JSON text and string=\"false\".\n\n"
|
||
"Read defaults to a bounded chunk: path alone returns the first 500 lines, not the whole file. "
|
||
"If read says more lines are available, call more with count=<lines> to read the next chunk; "
|
||
"more defaults to the next 500 lines. "
|
||
"The read result also reports continue_offset=N, which is the next start_line if you need to jump manually. "
|
||
"If the user explicitly asks you to read a complete file into context, call read with whole=true. "
|
||
"A whole-file read may fail if the result would not fit the current context; then explain that and use chunks.\n\n";
|
||
|
||
static const char agent_tools_prompt_edit_line[] =
|
||
"## Editing files\n\n"
|
||
"Use write for new files or deliberate whole-file replacement. Use edit with path, old, and new for changes. "
|
||
"For edit, always put the edited file path as the first parameter. "
|
||
"The old text must match exactly once in the current file; otherwise edit fails for safety.\n"
|
||
"For large replacements, prefer anchored old text: write the first lines, then [upto], then the final lines. "
|
||
"The tool replaces everything from the head through the tail. If the head or tail is ambiguous, the edit fails.\n"
|
||
"After [upto], always write unique final lines before closing old; never close old immediately after [upto].\n"
|
||
"Do not use a generic tail anchor like:\n"
|
||
"- BigNum bignum_add(BigNum *a, BigNum *b) {\n"
|
||
"- [upto]\n"
|
||
"- }\n"
|
||
"because the closing brace may match many functions. Instead include final lines that are unique near that function, "
|
||
"for example its last calculation and return line before the brace.\n"
|
||
"Example anchored edit:\n"
|
||
"<|DSML|tool_calls>\n"
|
||
"<|DSML|invoke name=\"edit\">\n"
|
||
"<|DSML|parameter name=\"path\" string=\"true\">/tmp/example.c</|DSML|parameter>\n"
|
||
"<|DSML|parameter name=\"old\" string=\"true\">static int parse(void) {\n"
|
||
" int ok = 0;\n"
|
||
"[upto]\n"
|
||
" return ok;\n"
|
||
"}</|DSML|parameter>\n"
|
||
"<|DSML|parameter name=\"new\" string=\"true\">static int parse(void) {\n"
|
||
" return parse_impl();\n"
|
||
"}</|DSML|parameter>\n"
|
||
"</|DSML|invoke>\n"
|
||
"</|DSML|tool_calls>\n"
|
||
"To insert text, use edit with old set to an exact unique anchor and new set to that anchor plus the added text.\n"
|
||
"Use read raw=true only when you need plain file text without line numbers or read annotations.\n\n";
|
||
|
||
static const char agent_tools_prompt_after_edit[] =
|
||
"For long-running bash commands, pass refresh_sec. If a bash job is still running, use "
|
||
"bash_status to check it early or bash_stop to terminate it.\n\n"
|
||
"Use google_search to find web pages. Use visit_page to read a known URL with a visible browser. "
|
||
"The first web call may ask the user for permission to start Chrome.\n\n"
|
||
"### Available Tool Schemas\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"google_search\",\n"
|
||
" \"description\": \"Search Google in a visible browser and return compact Markdown links.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"query\": {\"type\": \"string\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"query\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"visit_page\",\n"
|
||
" \"description\": \"Open a URL in a visible browser and return rendered page Markdown.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"url\": {\"type\": \"string\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"url\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"bash\",\n"
|
||
" \"description\": \"Run a shell command.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"command\": {\"type\": \"string\"},\n"
|
||
" \"timeout_sec\": {\"type\": \"number\"},\n"
|
||
" \"refresh_sec\": {\"type\": \"number\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"command\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"bash_status\",\n"
|
||
" \"description\": \"Report current status and new output for a bash job.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"job\": {\"type\": \"number\"},\n"
|
||
" \"pid\": {\"type\": \"number\"},\n"
|
||
" \"refresh_sec\": {\"type\": \"number\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"job\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"bash_stop\",\n"
|
||
" \"description\": \"Terminate a running bash job and report its final output.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"job\": {\"type\": \"number\"},\n"
|
||
" \"pid\": {\"type\": \"number\"},\n"
|
||
" \"refresh_sec\": {\"type\": \"number\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"job\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"read\",\n"
|
||
" \"description\": \"Read a text file or a range of lines.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"path\": {\"type\": \"string\"},\n"
|
||
" \"start_line\": {\"type\": \"number\"},\n"
|
||
" \"max_lines\": {\"type\": \"number\"},\n"
|
||
" \"whole\": {\"type\": \"boolean\"},\n"
|
||
" \"raw\": {\"type\": \"boolean\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"path\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"more\",\n"
|
||
" \"description\": \"Continue the previous read-like output.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"count\": {\"type\": \"number\"}\n"
|
||
" }\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"write\",\n"
|
||
" \"description\": \"Create or overwrite a text file.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"path\": {\"type\": \"string\"},\n"
|
||
" \"content\": {\"type\": \"string\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"path\", \"content\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"edit\",\n"
|
||
" \"description\": \"Replace exactly one old text match; old may contain [upto] between unique head and tail anchors.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"path\": {\"type\": \"string\"},\n"
|
||
" \"old\": {\"type\": \"string\"},\n"
|
||
" \"new\": {\"type\": \"string\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"path\", \"old\", \"new\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"search\",\n"
|
||
" \"description\": \"Search files and return compact edit-friendly matches.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"query\": {\"type\": \"string\"},\n"
|
||
" \"path\": {\"type\": \"string\"},\n"
|
||
" \"mode\": {\"type\": \"string\"},\n"
|
||
" \"glob\": {\"type\": \"string\"},\n"
|
||
" \"context\": {\"type\": \"number\"},\n"
|
||
" \"max_results\": {\"type\": \"number\"},\n"
|
||
" \"case_sensitive\": {\"type\": \"boolean\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"query\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n\n"
|
||
"{\n"
|
||
" \"type\": \"function\",\n"
|
||
" \"function\": {\n"
|
||
" \"name\": \"list\",\n"
|
||
" \"description\": \"List one directory compactly.\",\n"
|
||
" \"parameters\": {\n"
|
||
" \"type\": \"object\",\n"
|
||
" \"properties\": {\n"
|
||
" \"path\": {\"type\": \"string\"}\n"
|
||
" },\n"
|
||
" \"required\": [\"path\"]\n"
|
||
" }\n"
|
||
" }\n"
|
||
"}\n"
|
||
"\n"
|
||
"# Rules\n\n"
|
||
"- Always use strict syntax for DSML tool stanzas.\n"
|
||
"- This system runs on local inference of a few hundred tokens/s of prefill, "
|
||
"and a few tens of tokens/s decoding speed. Use read/search to get the "
|
||
"anchors you need, then use anchored edit to avoid having to "
|
||
"retype large text.\n"
|
||
"- Write code that is reliable and works well; always have a mental model of "
|
||
"what is going on in complex parts of the code.\n"
|
||
"- Work in a way that preserves the current system configuration integrity, "
|
||
"unless explicitly asked otherwise by the user.\n";
|
||
|
||
static char *agent_build_tools_prompt(void) {
|
||
const char *edit = agent_tools_prompt_edit_line;
|
||
size_t a = strlen(agent_tools_prompt_intro);
|
||
size_t b = strlen(edit);
|
||
size_t c = strlen(agent_tools_prompt_after_edit);
|
||
char *out = xmalloc(a + b + c + 1);
|
||
memcpy(out, agent_tools_prompt_intro, a);
|
||
memcpy(out + a, edit, b);
|
||
memcpy(out + a + b, agent_tools_prompt_after_edit, c + 1);
|
||
return out;
|
||
}
|
||
|
||
static const char agent_dsml_syntax_reminder[] =
|
||
"DSML syntax reminder:\n"
|
||
"<|DSML|tool_calls>\n"
|
||
"<|DSML|invoke name=\"$TOOL_NAME\">\n"
|
||
"<|DSML|parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</|DSML|parameter>\n"
|
||
"</|DSML|invoke>\n"
|
||
"</|DSML|tool_calls>\n";
|
||
|
||
#define AGENT_SYSTEM_PROMPT_REMINDER_TOKENS 50000
|
||
|
||
static char *agent_build_system_prompt_reminder(void) {
|
||
char *tools = agent_build_tools_prompt();
|
||
const char *start = "\n\n[System prompt reminder follows.]\n";
|
||
const char *end = "[End system prompt reminder.]\n\n";
|
||
size_t len = strlen(start) + strlen(tools) + strlen(end) + 1;
|
||
char *out = xmalloc(len);
|
||
out[0] = '\0';
|
||
strcat(out, start);
|
||
strcat(out, tools);
|
||
strcat(out, end);
|
||
free(tools);
|
||
return out;
|
||
}
|
||
|
||
static void agent_append_system_prompt(ds4_engine *engine, ds4_tokens *tokens,
|
||
const char *extra) {
|
||
/* The built-in tool prompt is trusted DS4 control text. Tokenize it like a
|
||
* rendered chat prompt so the literal |DSML| markers in the examples become
|
||
* the model's dedicated DSML token. Do not apply that tokenizer to user
|
||
* supplied -sys text: arbitrary user text containing <|User|>, <think>, or
|
||
* |DSML| must remain plain content, not control tokens. */
|
||
char *tools_prompt = agent_build_tools_prompt();
|
||
ds4_tokenize_rendered_chat(engine, tools_prompt, tokens);
|
||
free(tools_prompt);
|
||
|
||
if (!extra || !extra[0]) return;
|
||
size_t n = strlen(extra);
|
||
char *plain = xmalloc(n + 3);
|
||
memcpy(plain, "\n\n", 2);
|
||
memcpy(plain + 2, extra, n + 1);
|
||
ds4_chat_append_message(engine, tokens, "system", plain);
|
||
free(plain);
|
||
}
|
||
|
||
static void agent_worker_note_system_prompt_seen(agent_worker *w) {
|
||
w->last_system_prompt_reminder_at = w->transcript.len;
|
||
}
|
||
|
||
static void agent_worker_maybe_append_datetime_context(agent_worker *w) {
|
||
if (w->datetime_context_injected) return;
|
||
|
||
time_t now = time(NULL);
|
||
struct tm tm;
|
||
localtime_r(&now, &tm);
|
||
|
||
char when[128];
|
||
if (strftime(when, sizeof(when), "%Y-%m-%d %H:%M:%S %Z", &tm) == 0)
|
||
snprintf(when, sizeof(when), "%lld", (long long)now);
|
||
|
||
char msg[256];
|
||
snprintf(msg, sizeof(msg),
|
||
"Current local date and time at session start: %s. "
|
||
"Use this only when date or time matters.", when);
|
||
ds4_chat_append_message(w->engine, &w->transcript, "system", msg);
|
||
agent_trace_text(w, "datetime-context", msg, strlen(msg));
|
||
w->datetime_context_injected = true;
|
||
}
|
||
|
||
/* The full tool/system reminder is separate from DSML syntax errors: it is a
|
||
* pressure-controlled refresh of the same trusted prompt shape used at startup.
|
||
* The built-in prompt is tokenized as rendered chat so DSML markers stay native
|
||
* control tokens; arbitrary -sys text remains ordinary text. */
|
||
static void agent_worker_maybe_append_system_prompt_reminder(agent_worker *w) {
|
||
if (w->last_system_prompt_reminder_at <= 0) {
|
||
agent_worker_note_system_prompt_seen(w);
|
||
return;
|
||
}
|
||
if (w->transcript.len - w->last_system_prompt_reminder_at <
|
||
AGENT_SYSTEM_PROMPT_REMINDER_TOKENS)
|
||
{
|
||
return;
|
||
}
|
||
|
||
char *reminder = agent_build_system_prompt_reminder();
|
||
agent_publish_system_status(w, "Re-injecting system prompt reminder...");
|
||
agent_trace(w, "system prompt reminder injected at transcript=%d",
|
||
w->transcript.len);
|
||
ds4_tokenize_rendered_chat(w->engine, reminder, &w->transcript);
|
||
free(reminder);
|
||
|
||
const char *extra = w->cfg->gen.system;
|
||
if (extra && extra[0]) {
|
||
ds4_tokenize_text(w->engine,
|
||
"\nAdditional system instructions reminder:\n", &w->transcript);
|
||
ds4_tokenize_text(w->engine, extra, &w->transcript);
|
||
ds4_tokenize_text(w->engine,
|
||
"\n[End additional system instructions reminder.]\n\n",
|
||
&w->transcript);
|
||
}
|
||
agent_worker_note_system_prompt_seen(w);
|
||
}
|
||
|
||
/* Wake the UI thread after changing worker-visible state. The byte in
|
||
* wake_fd is level-triggered with wake_pending so bursts of sampled tokens do
|
||
* not flood the pipe. */
|
||
static void agent_wake_locked(agent_worker *w) {
|
||
if (w->wake_pending) return;
|
||
w->wake_pending = true;
|
||
char c = 'x';
|
||
ssize_t wr = write(w->wake_fd[1], &c, 1);
|
||
(void)wr;
|
||
}
|
||
|
||
/* Queue rendered output for the UI thread. The worker never writes directly
|
||
* to the terminal, which keeps linenoise redraws serialized in one place. */
|
||
static void agent_publish(agent_worker *w, const char *s, size_t n) {
|
||
if (!n) return;
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->out_len + n + 1 > w->out_cap) {
|
||
size_t cap = w->out_cap ? w->out_cap * 2 : 4096;
|
||
while (cap < w->out_len + n + 1) cap *= 2;
|
||
char *p = realloc(w->out, cap);
|
||
if (!p) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
return;
|
||
}
|
||
w->out = p;
|
||
w->out_cap = cap;
|
||
}
|
||
memcpy(w->out + w->out_len, s, n);
|
||
w->out_len += n;
|
||
w->out[w->out_len] = '\0';
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void agent_publishf(agent_worker *w, const char *fmt, ...) {
|
||
char stack[1024];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
int n = vsnprintf(stack, sizeof(stack), fmt, ap);
|
||
va_end(ap);
|
||
if (n <= 0) return;
|
||
if ((size_t)n < sizeof(stack)) {
|
||
agent_publish(w, stack, (size_t)n);
|
||
return;
|
||
}
|
||
|
||
char *heap = xmalloc((size_t)n + 1);
|
||
va_start(ap, fmt);
|
||
vsnprintf(heap, (size_t)n + 1, fmt, ap);
|
||
va_end(ap);
|
||
agent_publish(w, heap, (size_t)n);
|
||
free(heap);
|
||
}
|
||
|
||
static bool worker_is_idle(agent_worker *w);
|
||
|
||
static void agent_set_status(agent_worker *w, agent_worker_state state) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.state = state;
|
||
if (state != AGENT_WORKER_PREFILL)
|
||
w->status.prefill_tps = 0.0;
|
||
if (state != AGENT_WORKER_GENERATING)
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void agent_set_error(agent_worker *w, const char *msg) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.state = AGENT_WORKER_ERROR;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
snprintf(w->status.error, sizeof(w->status.error), "%s", msg ? msg : "unknown error");
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Trace Logging
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void agent_trace_time(FILE *fp) {
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_REALTIME, &ts);
|
||
struct tm tm;
|
||
localtime_r(&ts.tv_sec, &tm);
|
||
fprintf(fp, "%04d-%02d-%02d %02d:%02d:%02d.%03ld",
|
||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||
tm.tm_hour, tm.tm_min, tm.tm_sec, ts.tv_nsec / 1000000);
|
||
}
|
||
|
||
static void agent_trace(agent_worker *w, const char *fmt, ...) {
|
||
if (!w || !w->trace) return;
|
||
pthread_mutex_lock(&w->mu);
|
||
agent_trace_time(w->trace);
|
||
fputs(" ", w->trace);
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
vfprintf(w->trace, fmt, ap);
|
||
va_end(ap);
|
||
fputc('\n', w->trace);
|
||
fflush(w->trace);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void agent_trace_escaped(FILE *fp, const char *s, size_t n) {
|
||
for (size_t i = 0; i < n; i++) {
|
||
unsigned char c = (unsigned char)s[i];
|
||
switch (c) {
|
||
case '\\': fputs("\\\\", fp); break;
|
||
case '\n': fputs("\\n", fp); break;
|
||
case '\r': fputs("\\r", fp); break;
|
||
case '\t': fputs("\\t", fp); break;
|
||
case '"': fputs("\\\"", fp); break;
|
||
default:
|
||
if (c < 32 || c == 127) fprintf(fp, "\\x%02x", c);
|
||
else fputc(c, fp);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
static void agent_trace_token(agent_worker *w, int token, const char *text,
|
||
size_t text_len, int index) {
|
||
if (!w || !w->trace) return;
|
||
pthread_mutex_lock(&w->mu);
|
||
agent_trace_time(w->trace);
|
||
fprintf(w->trace, " token index=%d id=%d bytes=%zu text=\"",
|
||
index, token, text_len);
|
||
agent_trace_escaped(w->trace, text ? text : "", text_len);
|
||
fputs("\" hex=", w->trace);
|
||
for (size_t i = 0; i < text_len; i++)
|
||
fprintf(w->trace, "%02x", (unsigned char)text[i]);
|
||
fputc('\n', w->trace);
|
||
fflush(w->trace);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void agent_trace_tokens(agent_worker *w, const char *label,
|
||
const ds4_tokens *tokens, int start) {
|
||
if (!w || !w->trace || !tokens) return;
|
||
if (start < 0) start = 0;
|
||
if (start > tokens->len) start = tokens->len;
|
||
agent_trace(w, "tokens label=%s start=%d len=%d", label ? label : "",
|
||
start, tokens->len);
|
||
for (int i = start; i < tokens->len; i++) {
|
||
size_t text_len = 0;
|
||
char *text = ds4_token_text(w->engine, tokens->v[i], &text_len);
|
||
agent_trace_token(w, tokens->v[i], text, text_len, i);
|
||
free(text);
|
||
}
|
||
}
|
||
|
||
static void agent_trace_text(agent_worker *w, const char *label,
|
||
const char *text, size_t len) {
|
||
if (!w || !w->trace) return;
|
||
pthread_mutex_lock(&w->mu);
|
||
agent_trace_time(w->trace);
|
||
fprintf(w->trace, " %s=\"", label ? label : "text");
|
||
agent_trace_escaped(w->trace, text ? text : "", len);
|
||
fputs("\"\n", w->trace);
|
||
fflush(w->trace);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* DSML Tool-Call Parser
|
||
* ============================================================================
|
||
*
|
||
* The model streams raw text tokens. This parser recognizes completed DSML
|
||
* tool stanzas and keeps a copy of the raw stanza for diagnostics. It is
|
||
* deliberately strict after the opening marker: typo recovery belongs to the
|
||
* streaming detector so the actual tool parser stays small and predictable.
|
||
*/
|
||
|
||
static bool bytes_has_prefix(const char *p, size_t n, const char *prefix) {
|
||
size_t plen = strlen(prefix);
|
||
return n >= plen && memcmp(p, prefix, plen) == 0;
|
||
}
|
||
|
||
static bool bytes_is_partial_prefix(const char *p, size_t n, const char *prefix) {
|
||
size_t plen = strlen(prefix);
|
||
return n < plen && memcmp(prefix, p, n) == 0;
|
||
}
|
||
|
||
static void agent_tool_call_free(agent_tool_call *c) {
|
||
if (!c) return;
|
||
free(c->name);
|
||
for (int i = 0; i < c->argc; i++) {
|
||
free(c->args[i].name);
|
||
free(c->args[i].value);
|
||
}
|
||
free(c->args);
|
||
memset(c, 0, sizeof(*c));
|
||
}
|
||
|
||
static void agent_tool_calls_free(agent_tool_calls *calls) {
|
||
if (!calls) return;
|
||
for (int i = 0; i < calls->len; i++) agent_tool_call_free(&calls->v[i]);
|
||
free(calls->v);
|
||
memset(calls, 0, sizeof(*calls));
|
||
}
|
||
|
||
static void agent_tool_call_add_arg(agent_tool_call *c, const char *name,
|
||
const char *value, size_t value_len,
|
||
bool is_string) {
|
||
if (c->argc == c->argcap) {
|
||
c->argcap = c->argcap ? c->argcap * 2 : 4;
|
||
c->args = xrealloc(c->args, (size_t)c->argcap * sizeof(c->args[0]));
|
||
}
|
||
c->args[c->argc++] = (agent_tool_arg){
|
||
.name = xstrdup(name),
|
||
.value = xstrndup(value, value_len),
|
||
.is_string = is_string,
|
||
};
|
||
}
|
||
|
||
static void agent_tool_calls_push(agent_tool_calls *calls, agent_tool_call *call) {
|
||
if (!call->name) return;
|
||
if (calls->len == calls->cap) {
|
||
calls->cap = calls->cap ? calls->cap * 2 : 2;
|
||
calls->v = xrealloc(calls->v, (size_t)calls->cap * sizeof(calls->v[0]));
|
||
}
|
||
calls->v[calls->len++] = *call;
|
||
memset(call, 0, sizeof(*call));
|
||
}
|
||
|
||
static const char *agent_tool_arg_value(const agent_tool_call *call, const char *name) {
|
||
for (int i = 0; i < call->argc; i++) {
|
||
if (call->args[i].name && !strcmp(call->args[i].name, name))
|
||
return call->args[i].value ? call->args[i].value : "";
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static void agent_dsml_parser_free(agent_dsml_parser *p) {
|
||
if (!p) return;
|
||
free(p->raw);
|
||
agent_tool_call_free(&p->current);
|
||
free(p->param_name);
|
||
agent_tool_calls_free(&p->calls);
|
||
memset(p, 0, sizeof(*p));
|
||
}
|
||
|
||
static void agent_dsml_parser_reset(agent_dsml_parser *p) {
|
||
agent_dsml_parser_free(p);
|
||
p->state = AGENT_DSML_SEARCH;
|
||
}
|
||
|
||
static void agent_dsml_raw_append(agent_dsml_parser *p, const char *s, size_t n) {
|
||
if (!n) return;
|
||
if (p->raw_len + n + 1 > p->raw_cap) {
|
||
size_t cap = p->raw_cap ? p->raw_cap * 2 : 512;
|
||
while (cap < p->raw_len + n + 1) cap *= 2;
|
||
p->raw = xrealloc(p->raw, cap);
|
||
p->raw_cap = cap;
|
||
}
|
||
memcpy(p->raw + p->raw_len, s, n);
|
||
p->raw_len += n;
|
||
p->raw[p->raw_len] = '\0';
|
||
}
|
||
|
||
static char *agent_parse_attr(const char *tag, const char *name) {
|
||
char pat[64];
|
||
snprintf(pat, sizeof(pat), "%s=\"", name);
|
||
const char *p = strstr(tag, pat);
|
||
if (!p) return NULL;
|
||
p += strlen(pat);
|
||
const char *end = strchr(p, '"');
|
||
if (!end) return NULL;
|
||
return xstrndup(p, (size_t)(end - p));
|
||
}
|
||
|
||
static void agent_dsml_set_error(agent_dsml_parser *p, const char *msg) {
|
||
p->state = AGENT_DSML_ERROR;
|
||
snprintf(p->error, sizeof(p->error), "%s", msg);
|
||
}
|
||
|
||
static bool agent_dsml_open_tag_is(const char *tag, const char *name) {
|
||
char prefix[64];
|
||
snprintf(prefix, sizeof(prefix), "<|DSML|%s", name);
|
||
size_t prefix_len = strlen(prefix);
|
||
if (strncmp(tag, prefix, prefix_len) != 0) return false;
|
||
char c = tag[prefix_len];
|
||
return c == '>' || c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||
}
|
||
|
||
static bool agent_dsml_close_tag_at(const char *s, const char *name, size_t *tag_len) {
|
||
char prefix[64];
|
||
static const char dsml_bar[] = "|";
|
||
snprintf(prefix, sizeof(prefix), "</|DSML|%s", name);
|
||
size_t prefix_len = strlen(prefix);
|
||
if (strncmp(s, prefix, prefix_len) != 0) return false;
|
||
const char *p = s + prefix_len;
|
||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||
if (strncmp(p, dsml_bar, strlen(dsml_bar)) == 0) p += strlen(dsml_bar);
|
||
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
|
||
if (*p != '>') return false;
|
||
if (tag_len) *tag_len = (size_t)(p - s) + 1;
|
||
return true;
|
||
}
|
||
|
||
/* Recognize a streamed parameter close tag prefix. Full close detection is
|
||
* handled by agent_dsml_close_tag_at(); this helper exists for online behavior:
|
||
* terminal rendering must hide partial close tags without waiting for the whole
|
||
* parameter to finish. */
|
||
static bool agent_dsml_parameter_close_tail(const char *tail, size_t len,
|
||
bool *complete) {
|
||
static const char prefix[] = "</|DSML|parameter";
|
||
static const char dsml_bar[] = "|";
|
||
const size_t prefix_len = sizeof(prefix) - 1;
|
||
const size_t bar_len = sizeof(dsml_bar) - 1;
|
||
*complete = false;
|
||
if (len <= prefix_len) return memcmp(prefix, tail, len) == 0;
|
||
if (memcmp(prefix, tail, prefix_len) != 0) return false;
|
||
size_t i = prefix_len;
|
||
while (i < len && (tail[i] == ' ' || tail[i] == '\t' ||
|
||
tail[i] == '\r' || tail[i] == '\n')) i++;
|
||
if (i < len && len - i <= bar_len) {
|
||
if (memcmp(dsml_bar, tail + i, len - i) == 0) return true;
|
||
}
|
||
if (i + bar_len <= len && memcmp(tail + i, dsml_bar, bar_len) == 0)
|
||
i += bar_len;
|
||
for (; i < len; i++) {
|
||
if (tail[i] == '>') {
|
||
*complete = i == len - 1;
|
||
return *complete;
|
||
}
|
||
if (tail[i] != ' ' && tail[i] != '\t' && tail[i] != '\r' && tail[i] != '\n')
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void agent_dsml_update_param_close_prefix(agent_dsml_parser *p) {
|
||
p->param_close_prefix = false;
|
||
if (p->state != AGENT_DSML_PARAM_VALUE || p->raw_len <= p->param_value_start)
|
||
return;
|
||
|
||
const char *value = p->raw + p->param_value_start;
|
||
const char *end = p->raw + p->raw_len;
|
||
const char *lt = end;
|
||
while (lt > value) {
|
||
lt--;
|
||
if (*lt == '<') break;
|
||
}
|
||
if (lt < value || *lt != '<') return;
|
||
|
||
size_t tail_len = (size_t)(end - lt);
|
||
if (tail_len > 64) return;
|
||
bool complete = false;
|
||
static const char dsml_marker[] = "</|DSML|";
|
||
p->param_close_prefix =
|
||
tail_len >= sizeof(dsml_marker) - 1 &&
|
||
memcmp(lt, dsml_marker, sizeof(dsml_marker) - 1) == 0 &&
|
||
agent_dsml_parameter_close_tail(lt, tail_len, &complete) &&
|
||
!complete;
|
||
}
|
||
|
||
/* Find a DSML closing tag while accepting the few harmless closing-tag variants
|
||
* the model has been observed to emit. Opening tags stay strict so accidental
|
||
* prose does not become a tool call. */
|
||
static char *agent_dsml_find_close_tag(const char *s, const char *name, size_t *tag_len) {
|
||
const char *p = s;
|
||
while ((p = strstr(p, "</|DSML|")) != NULL) {
|
||
if (agent_dsml_close_tag_at(p, name, tag_len)) return (char *)p;
|
||
p++;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Parse as much of the accumulated DSML buffer as possible. The parser can be
|
||
* called after every streamed byte: incomplete input leaves state unchanged
|
||
* until enough bytes arrive, while malformed completed input switches to
|
||
* AGENT_DSML_ERROR so the model gets a retryable tool error. */
|
||
static void agent_dsml_parse(agent_dsml_parser *p) {
|
||
while (p->state == AGENT_DSML_STRUCTURAL || p->state == AGENT_DSML_PARAM_VALUE) {
|
||
if (p->state == AGENT_DSML_PARAM_VALUE) {
|
||
size_t end_tag_len = 0;
|
||
char *end = agent_dsml_find_close_tag(p->raw + p->param_value_start,
|
||
"parameter", &end_tag_len);
|
||
if (!end) return;
|
||
agent_tool_call_add_arg(&p->current, p->param_name ? p->param_name : "",
|
||
p->raw + p->param_value_start,
|
||
(size_t)(end - (p->raw + p->param_value_start)),
|
||
p->param_is_string);
|
||
p->param_close_prefix = false;
|
||
free(p->param_name);
|
||
p->param_name = NULL;
|
||
p->parse_pos = (size_t)(end - p->raw) + end_tag_len;
|
||
p->state = AGENT_DSML_STRUCTURAL;
|
||
continue;
|
||
}
|
||
|
||
while (p->parse_pos < p->raw_len &&
|
||
(p->raw[p->parse_pos] == ' ' || p->raw[p->parse_pos] == '\t' ||
|
||
p->raw[p->parse_pos] == '\r' || p->raw[p->parse_pos] == '\n'))
|
||
p->parse_pos++;
|
||
if (p->parse_pos >= p->raw_len) return;
|
||
|
||
size_t close_len = 0;
|
||
if (agent_dsml_close_tag_at(p->raw + p->parse_pos, "tool_calls", &close_len)) {
|
||
agent_tool_calls_push(&p->calls, &p->current);
|
||
p->parse_pos += close_len;
|
||
p->state = AGENT_DSML_DONE;
|
||
return;
|
||
}
|
||
if (agent_dsml_close_tag_at(p->raw + p->parse_pos, "invoke", &close_len)) {
|
||
agent_tool_calls_push(&p->calls, &p->current);
|
||
p->parse_pos += close_len;
|
||
continue;
|
||
}
|
||
|
||
char *tag_end = strchr(p->raw + p->parse_pos, '>');
|
||
if (!tag_end) return;
|
||
size_t tag_len = (size_t)(tag_end - (p->raw + p->parse_pos)) + 1;
|
||
char *tag = xstrndup(p->raw + p->parse_pos, tag_len);
|
||
|
||
if (agent_dsml_open_tag_is(tag, "invoke")) {
|
||
agent_tool_call_free(&p->current);
|
||
p->current.name = agent_parse_attr(tag, "name");
|
||
if (!p->current.name) {
|
||
free(tag);
|
||
agent_dsml_set_error(p, "tool invoke without name");
|
||
return;
|
||
}
|
||
p->parse_pos += tag_len;
|
||
} else if (agent_dsml_open_tag_is(tag, "parameter")) {
|
||
free(p->param_name);
|
||
p->param_name = agent_parse_attr(tag, "name");
|
||
char *is_string = agent_parse_attr(tag, "string");
|
||
p->param_is_string = is_string && !strcmp(is_string, "true");
|
||
free(is_string);
|
||
if (!p->param_name) {
|
||
free(tag);
|
||
agent_dsml_set_error(p, "tool parameter without name");
|
||
return;
|
||
}
|
||
p->parse_pos += tag_len;
|
||
p->param_value_start = p->parse_pos;
|
||
p->param_close_prefix = false;
|
||
p->state = AGENT_DSML_PARAM_VALUE;
|
||
} else {
|
||
snprintf(p->error, sizeof(p->error), "unexpected DSML tag: %.*s",
|
||
(int)(tag_len > 80 ? 80 : tag_len), tag);
|
||
free(tag);
|
||
p->state = AGENT_DSML_ERROR;
|
||
return;
|
||
}
|
||
free(tag);
|
||
}
|
||
}
|
||
|
||
static void agent_dsml_start(agent_dsml_parser *p) {
|
||
static const char start[] = "<|DSML|tool_calls>";
|
||
p->state = AGENT_DSML_STRUCTURAL;
|
||
p->search_len = 0;
|
||
agent_dsml_raw_append(p, start, strlen(start));
|
||
p->parse_pos = strlen(start);
|
||
}
|
||
|
||
static void agent_dsml_feed(agent_dsml_parser *p, const char *s, size_t n) {
|
||
static const char start[] = "<|DSML|tool_calls>";
|
||
const size_t start_len = sizeof(start) - 1;
|
||
if (p->state == AGENT_DSML_DONE || p->state == AGENT_DSML_ERROR) return;
|
||
|
||
for (size_t i = 0; i < n; i++) {
|
||
char c = s[i];
|
||
if (p->state == AGENT_DSML_SEARCH) {
|
||
if (p->search_len == sizeof(p->search_tail)) {
|
||
memmove(p->search_tail, p->search_tail + 1, --p->search_len);
|
||
}
|
||
p->search_tail[p->search_len++] = c;
|
||
if (p->search_len >= start_len &&
|
||
memcmp(p->search_tail + p->search_len - start_len, start, start_len) == 0)
|
||
agent_dsml_start(p);
|
||
continue;
|
||
}
|
||
|
||
agent_dsml_raw_append(p, &c, 1);
|
||
agent_dsml_parse(p);
|
||
if (p->state == AGENT_DSML_PARAM_VALUE)
|
||
agent_dsml_update_param_close_prefix(p);
|
||
else
|
||
p->param_close_prefix = false;
|
||
}
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Assistant Markdown Rendering
|
||
* ============================================================================
|
||
*
|
||
* This renderer handles only the cheap markdown cues that make terminal output
|
||
* readable: **bold**, *italic*, inline code, and fenced code blocks. It is a
|
||
* streaming parser, so it buffers only ambiguous marker bytes long enough to
|
||
* decide whether they are formatting or literal text.
|
||
*/
|
||
|
||
static void agent_tail_capture_append(agent_tail_capture *t,
|
||
const char *s, size_t n) {
|
||
if (!t || !n) return;
|
||
if (!t->cap) return;
|
||
if (!t->buf) t->buf = xmalloc(t->cap);
|
||
t->total += n;
|
||
|
||
if (n >= t->cap) {
|
||
memcpy(t->buf, s + n - t->cap, t->cap);
|
||
t->start = 0;
|
||
t->len = t->cap;
|
||
return;
|
||
}
|
||
|
||
if (t->len < t->cap) {
|
||
size_t free_tail = t->cap - t->len;
|
||
size_t first = n < free_tail ? n : free_tail;
|
||
size_t pos = (t->start + t->len) % t->cap;
|
||
size_t right = t->cap - pos;
|
||
size_t chunk = first < right ? first : right;
|
||
memcpy(t->buf + pos, s, chunk);
|
||
if (first > chunk) memcpy(t->buf, s + chunk, first - chunk);
|
||
t->len += first;
|
||
s += first;
|
||
n -= first;
|
||
}
|
||
|
||
while (n) {
|
||
size_t pos = (t->start + t->len) % t->cap;
|
||
size_t right = t->cap - pos;
|
||
size_t chunk = n < right ? n : right;
|
||
memcpy(t->buf + pos, s, chunk);
|
||
t->start = (t->start + chunk) % t->cap;
|
||
s += chunk;
|
||
n -= chunk;
|
||
}
|
||
}
|
||
|
||
static char *agent_tail_capture_take(agent_tail_capture *t, size_t *len) {
|
||
size_t n = t ? t->len : 0;
|
||
char *out = xmalloc(n + 1);
|
||
if (n) {
|
||
size_t right = t->cap - t->start;
|
||
size_t first = n < right ? n : right;
|
||
memcpy(out, t->buf + t->start, first);
|
||
if (n > first) memcpy(out + first, t->buf, n - first);
|
||
}
|
||
out[n] = '\0';
|
||
if (len) *len = n;
|
||
free(t->buf);
|
||
memset(t, 0, sizeof(*t));
|
||
return out;
|
||
}
|
||
|
||
static void renderer_write(agent_token_renderer *r, const char *s, size_t n) {
|
||
if (r->capture) agent_tail_capture_append(r->capture, s, n);
|
||
else agent_publish(r->worker, s, n);
|
||
}
|
||
|
||
static void renderer_set_grey(agent_token_renderer *r) {
|
||
if (r->use_color) renderer_write(r, "\x1b[38;5;245m", 11);
|
||
}
|
||
|
||
static void renderer_reset_color(agent_token_renderer *r) {
|
||
if (r->use_color) renderer_write(r, "\x1b[0m", 4);
|
||
r->color_open = false;
|
||
}
|
||
|
||
static size_t renderer_utf8_need(unsigned char c) {
|
||
if (c < 0x80) return 1;
|
||
if (c >= 0xc2 && c <= 0xdf) return 2;
|
||
if (c >= 0xe0 && c <= 0xef) return 3;
|
||
if (c >= 0xf0 && c <= 0xf4) return 4;
|
||
return 1;
|
||
}
|
||
|
||
static bool renderer_has_text_attrs(agent_token_renderer *r) {
|
||
return r->in_think || r->md_bold || r->md_italic ||
|
||
r->md_inline_code || r->md_code_block;
|
||
}
|
||
|
||
static void renderer_set_text_attrs(agent_token_renderer *r) {
|
||
if (!r->use_color) return;
|
||
if (r->in_think) {
|
||
renderer_set_grey(r);
|
||
return;
|
||
}
|
||
if (r->md_code_block) {
|
||
renderer_write(r, "\x1b[38;5;75m", 10);
|
||
return;
|
||
} else if (r->md_inline_code) {
|
||
renderer_write(r, "\x1b[36m", 5);
|
||
}
|
||
if (r->md_bold) renderer_write(r, "\x1b[1m", 4);
|
||
if (r->md_italic) renderer_write(r, "\x1b[3m", 4);
|
||
}
|
||
|
||
static void renderer_restore_text_attrs(agent_token_renderer *r) {
|
||
if (!r->use_color || !r->color_open || !renderer_has_text_attrs(r)) return;
|
||
renderer_set_text_attrs(r);
|
||
}
|
||
|
||
static void renderer_write_complete_char_raw(agent_token_renderer *r, const char *s, size_t n) {
|
||
bool styled = r->use_color && renderer_has_text_attrs(r);
|
||
if (styled && !r->color_open) {
|
||
renderer_set_text_attrs(r);
|
||
r->color_open = true;
|
||
} else if (!styled && r->color_open) {
|
||
renderer_reset_color(r);
|
||
}
|
||
renderer_write(r, s, n);
|
||
if (n) r->wrote_visible_output = true;
|
||
r->last_output_newline = n == 1 && s[0] == '\n';
|
||
}
|
||
|
||
static void renderer_flush_utf8(agent_token_renderer *r) {
|
||
if (!r->utf8_pending_len) return;
|
||
renderer_write_complete_char_raw(r, r->utf8_pending, r->utf8_pending_len);
|
||
r->utf8_pending_len = 0;
|
||
r->utf8_pending_need = 0;
|
||
}
|
||
|
||
static void renderer_write_char_raw(agent_token_renderer *r, char c) {
|
||
unsigned char uc = (unsigned char)c;
|
||
|
||
if (r->utf8_pending_len) {
|
||
if ((uc & 0xc0) == 0x80 && r->utf8_pending_len < sizeof(r->utf8_pending)) {
|
||
r->utf8_pending[r->utf8_pending_len++] = c;
|
||
if (r->utf8_pending_len == r->utf8_pending_need) renderer_flush_utf8(r);
|
||
return;
|
||
}
|
||
renderer_flush_utf8(r);
|
||
}
|
||
|
||
size_t need = renderer_utf8_need(uc);
|
||
if (need == 1) {
|
||
renderer_write_complete_char_raw(r, &c, 1);
|
||
return;
|
||
}
|
||
r->utf8_pending[0] = c;
|
||
r->utf8_pending_len = 1;
|
||
r->utf8_pending_need = need;
|
||
}
|
||
|
||
static void renderer_write_plain_byte(agent_token_renderer *r, char c) {
|
||
bool old_bold = r->md_bold;
|
||
bool old_italic = r->md_italic;
|
||
bool old_inline_code = r->md_inline_code;
|
||
bool old_code_block = r->md_code_block;
|
||
|
||
/* Code blocks are streamed immediately in plain text, then repainted with
|
||
* syntax colors when a complete terminal-safe line is available. Disable
|
||
* markdown attributes only for this byte; renderer_write_char_raw() will
|
||
* reset any tracked manual color once if needed. */
|
||
r->md_bold = false;
|
||
r->md_italic = false;
|
||
r->md_inline_code = false;
|
||
r->md_code_block = false;
|
||
renderer_write_char_raw(r, c);
|
||
r->md_bold = old_bold;
|
||
r->md_italic = old_italic;
|
||
r->md_inline_code = old_inline_code;
|
||
r->md_code_block = old_code_block;
|
||
}
|
||
|
||
/* Poor man's code highlighter inspired by antirez/kilo: a tiny language table
|
||
* plus one line-oriented tokenizer for comments, strings, numbers, and
|
||
* separator-bounded keywords. This is deliberately not a full parser; it is
|
||
* only for making fenced Markdown code readable in the terminal. */
|
||
#define AGENT_HL_NORMAL 0
|
||
#define AGENT_HL_COMMENT 1
|
||
#define AGENT_HL_KEYWORD1 2
|
||
#define AGENT_HL_KEYWORD2 3
|
||
#define AGENT_HL_STRING 4
|
||
#define AGENT_HL_NUMBER 5
|
||
|
||
#define AGENT_SYNTAX_NUMBERS (1u<<0)
|
||
#define AGENT_SYNTAX_STRINGS (1u<<1)
|
||
#define AGENT_SYNTAX_BACKTICK_STRINGS (1u<<2)
|
||
#define AGENT_SYNTAX_CASE_INSENSITIVE (1u<<3)
|
||
|
||
struct agent_syntax {
|
||
const char *name;
|
||
const char *aliases;
|
||
const char **keywords;
|
||
const char *singleline_comments[3];
|
||
const char *multiline_start;
|
||
const char *multiline_end;
|
||
unsigned flags;
|
||
};
|
||
|
||
static const char *agent_kw_generic[] = {
|
||
"if","else","for","while","do","switch","case","default","break",
|
||
"continue","return","try","catch","finally","throw","throws","class",
|
||
"struct","enum","interface","trait","impl","fn","func","function",
|
||
"def","lambda","let","var","const","static","public","private",
|
||
"protected","import","include","from","export","package","module",
|
||
"namespace","new","delete","async","await","yield","match","type",
|
||
"true|","false|","null|","nil|","none|","None|","NULL|","void|",
|
||
"int|","long|","float|","double|","char|","bool|","string|",
|
||
"String|","usize|","isize|","u8|","u16|","u32|","u64|","i8|",
|
||
"i16|","i32|","i64|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_c[] = {
|
||
"auto","break","case","continue","default","do","else","enum",
|
||
"extern","for","goto","if","register","return","sizeof","static",
|
||
"struct","switch","typedef","union","volatile","while",
|
||
"alignas","alignof","and","and_eq","asm","bitand","bitor","class",
|
||
"compl","constexpr","const_cast","decltype","delete","dynamic_cast",
|
||
"explicit","export","false","friend","inline","mutable","namespace",
|
||
"new","noexcept","not","not_eq","nullptr","operator","or","or_eq",
|
||
"private","protected","public","reinterpret_cast","static_assert",
|
||
"static_cast","template","this","thread_local","throw","true","try",
|
||
"typeid","typename","virtual","xor","xor_eq",
|
||
"NULL|","bool|","char|","const|","double|","float|","int|","long|",
|
||
"short|","signed|","size_t|","ssize_t|","uint8_t|","uint16_t|",
|
||
"uint32_t|","uint64_t|","unsigned|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_python[] = {
|
||
"and","as","assert","async","await","break","case","class","continue",
|
||
"def","del","elif","else","except","finally","for","from","global",
|
||
"if","import","in","is","lambda","match","nonlocal","not","or","pass",
|
||
"raise","return","try","while","with","yield",
|
||
"False|","None|","True|","bool|","bytes|","dict|","float|","int|",
|
||
"list|","object|","set|","str|","tuple|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_js[] = {
|
||
"async","await","break","case","catch","class","const","continue",
|
||
"debugger","default","delete","do","else","export","extends",
|
||
"finally","for","from","function","get","if","import","in",
|
||
"instanceof","let","new","of","return","set","static","super",
|
||
"switch","this","throw","try","typeof","var","void","while","with",
|
||
"yield","abstract","as","declare","enum","implements","interface",
|
||
"keyof","namespace","private","protected","public","readonly","type",
|
||
"any|","boolean|","false|","never|","null|","number|","string|",
|
||
"symbol|","true|","undefined|","unknown|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_java[] = {
|
||
"abstract","assert","break","case","catch","class","const","continue",
|
||
"default","do","else","enum","extends","final","finally","for","goto",
|
||
"if","implements","import","instanceof","interface","native","new",
|
||
"package","private","protected","public","return","static","strictfp",
|
||
"super","switch","synchronized","this","throw","throws","transient",
|
||
"try","volatile","while",
|
||
"boolean|","byte|","char|","double|","false|","float|","int|","long|",
|
||
"null|","short|","true|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_csharp[] = {
|
||
"abstract","as","base","break","case","catch","checked","class","const",
|
||
"continue","default","delegate","do","else","enum","event","explicit",
|
||
"extern","finally","fixed","for","foreach","goto","if","implicit","in",
|
||
"interface","internal","is","lock","namespace","new","operator","out",
|
||
"override","params","private","protected","public","readonly","ref",
|
||
"return","sealed","sizeof","stackalloc","static","struct","switch",
|
||
"this","throw","try","typeof","unchecked","unsafe","using","virtual",
|
||
"volatile","while","async","await","get","init","record","set","var",
|
||
"bool|","byte|","char|","decimal|","double|","false|","float|","int|",
|
||
"long|","null|","object|","sbyte|","short|","string|","true|","uint|",
|
||
"ulong|","ushort|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_go[] = {
|
||
"break","case","chan","const","continue","default","defer","else",
|
||
"fallthrough","for","func","go","goto","if","import","interface",
|
||
"map","package","range","return","select","struct","switch","type",
|
||
"var","bool|","byte|","complex64|","complex128|","error|","false|",
|
||
"float32|","float64|","int|","int8|","int16|","int32|","int64|",
|
||
"nil|","rune|","string|","true|","uint|","uint8|","uint16|",
|
||
"uint32|","uint64|","uintptr|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_rust[] = {
|
||
"as","async","await","break","const","continue","crate","dyn","else",
|
||
"enum","extern","fn","for","if","impl","in","let","loop","match",
|
||
"mod","move","mut","pub","ref","return","self","Self","static",
|
||
"struct","super","trait","type","unsafe","use","where","while",
|
||
"bool|","char|","false|","f32|","f64|","i8|","i16|","i32|","i64|",
|
||
"i128|","isize|","str|","String|","true|","u8|","u16|","u32|",
|
||
"u64|","u128|","usize|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_shell[] = {
|
||
"case","do","done","elif","else","esac","fi","for","function","if",
|
||
"in","select","then","time","until","while","break","continue",
|
||
"return","export","local","readonly","source","test","true|","false|",
|
||
"echo|","printf|","cd|","pwd|","read|","set|","unset|","shift|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_sql[] = {
|
||
"add","alter","and","as","asc","between","by","case","check","column",
|
||
"constraint","create","delete","desc","distinct","drop","else","end",
|
||
"exists","foreign","from","group","having","in","index","insert",
|
||
"into","is","join","key","left","like","limit","not","null","on",
|
||
"or","order","outer","primary","references","right","select","set",
|
||
"table","then","union","unique","update","values","view","where",
|
||
"bigint|","boolean|","date|","decimal|","false|","int|","integer|",
|
||
"numeric|","real|","text|","timestamp|","true|","varchar|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_ruby[] = {
|
||
"BEGIN","END","alias","and","begin","break","case","class","def",
|
||
"defined?","do","else","elsif","end","ensure","for","if","in",
|
||
"module","next","not","or","redo","rescue","retry","return","self",
|
||
"super","then","undef","unless","until","when","while","yield",
|
||
"false|","nil|","true|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_php[] = {
|
||
"abstract","and","array","as","break","callable","case","catch","class",
|
||
"clone","const","continue","declare","default","die","do","echo","else",
|
||
"elseif","empty","enddeclare","endfor","endforeach","endif","endswitch",
|
||
"endwhile","eval","exit","extends","final","finally","fn","for",
|
||
"foreach","function","global","goto","if","implements","include",
|
||
"include_once","instanceof","insteadof","interface","isset","list",
|
||
"match","namespace","new","or","print","private","protected","public",
|
||
"readonly","require","require_once","return","static","switch","throw",
|
||
"trait","try","unset","use","var","while","xor","bool|","false|",
|
||
"float|","int|","null|","string|","true|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_swift[] = {
|
||
"actor","as","associatedtype","async","await","break","case","catch",
|
||
"class","continue","default","defer","do","else","enum","extension",
|
||
"fallthrough","for","func","guard","if","import","in","init","inout",
|
||
"is","let","nonisolated","operator","private","protocol","public",
|
||
"repeat","return","self","Self","static","struct","subscript","super",
|
||
"switch","throw","throws","try","typealias","var","where","while",
|
||
"Any|","Bool|","Double|","false|","Float|","Int|","nil|","String|",
|
||
"true|","Void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_kotlin[] = {
|
||
"as","break","class","continue","do","else","false","for","fun","if",
|
||
"in","interface","is","null","object","package","return","super",
|
||
"this","throw","true","try","typealias","typeof","val","var","when",
|
||
"while","actual","annotation","by","catch","companion","const",
|
||
"constructor","crossinline","data","enum","expect","external","final",
|
||
"finally","import","infix","init","inline","inner","internal","lateinit",
|
||
"noinline","open","operator","out","override","private","protected",
|
||
"public","reified","sealed","suspend","tailrec","vararg",
|
||
"Any|","Boolean|","Byte|","Char|","Double|","Float|","Int|","Long|",
|
||
"Short|","String|","Unit|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_zig[] = {
|
||
"addrspace","align","allowzero","and","anyframe","anytype","asm",
|
||
"async","await","break","callconv","catch","comptime","const",
|
||
"continue","defer","else","enum","errdefer","error","export","extern",
|
||
"fn","for","if","inline","linksection","noalias","noinline","nosuspend",
|
||
"opaque","or","orelse","packed","pub","resume","return","struct",
|
||
"suspend","switch","test","threadlocal","try","union","unreachable",
|
||
"usingnamespace","var","volatile","while",
|
||
"bool|","false|","f32|","f64|","i32|","i64|","null|","true|","u8|",
|
||
"u16|","u32|","u64|","usize|","void|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_lua[] = {
|
||
"and","break","do","else","elseif","end","false","for","function",
|
||
"goto","if","in","local","nil","not","or","repeat","return","then",
|
||
"true","until","while",NULL
|
||
};
|
||
|
||
static const char *agent_kw_html[] = {
|
||
"a","body","button","div","doctype","form","h1","h2","h3","head",
|
||
"html","input","label","li","link","main","meta","ol","option","p",
|
||
"script","section","select","span","style","table","tbody","td","th",
|
||
"thead","title","tr","ul","class|","href|","id|","name|","rel|",
|
||
"src|","type|","value|",NULL
|
||
};
|
||
|
||
static const char *agent_kw_css[] = {
|
||
"align-items","background","border","bottom","color","display","flex",
|
||
"font","font-size","gap","grid","height","justify-content","left",
|
||
"margin","max-width","min-width","padding","position","right","top",
|
||
"transform","width","z-index","absolute|","auto|","block|","flex|",
|
||
"grid|","hidden|","inline|","none|","relative|","solid|",NULL
|
||
};
|
||
|
||
static const agent_syntax agent_syntaxes[] = {
|
||
{"generic", " text txt", agent_kw_generic, {"//","#",NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS | AGENT_SYNTAX_BACKTICK_STRINGS},
|
||
{"c", " c h cpp c++ cc cxx hpp hxx objc objective-c", agent_kw_c, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"python", " py python py3", agent_kw_python, {"#",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"javascript", " js jsx javascript typescript ts tsx node mjs cjs", agent_kw_js, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS | AGENT_SYNTAX_BACKTICK_STRINGS},
|
||
{"java", " java", agent_kw_java, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"csharp", " cs c# csharp dotnet", agent_kw_csharp, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"go", " go golang", agent_kw_go, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS | AGENT_SYNTAX_BACKTICK_STRINGS},
|
||
{"rust", " rs rust", agent_kw_rust, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"shell", " sh bash zsh shell fish ksh", agent_kw_shell, {"#",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS | AGENT_SYNTAX_BACKTICK_STRINGS},
|
||
{"sql", " sql postgres mysql sqlite", agent_kw_sql, {"--",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS | AGENT_SYNTAX_CASE_INSENSITIVE},
|
||
{"ruby", " rb ruby", agent_kw_ruby, {"#",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"php", " php", agent_kw_php, {"//","#",NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"swift", " swift", agent_kw_swift, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"kotlin", " kt kts kotlin", agent_kw_kotlin, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"zig", " zig", agent_kw_zig, {"//",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"lua", " lua", agent_kw_lua, {"--",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"html", " html htm xml svg", agent_kw_html, {NULL,NULL,NULL}, "<!--", "-->",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"css", " css scss sass", agent_kw_css, {NULL,NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"json", " json jsonc", NULL, {"//",NULL,NULL}, "/*", "*/",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"yaml", " yaml yml toml ini", NULL, {"#",NULL,NULL}, NULL, NULL,
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{"markdown", " md markdown", agent_kw_generic, {NULL,NULL,NULL}, "<!--", "-->",
|
||
AGENT_SYNTAX_NUMBERS | AGENT_SYNTAX_STRINGS},
|
||
{NULL, NULL, NULL, {NULL,NULL,NULL}, NULL, NULL, 0}
|
||
};
|
||
|
||
static bool agent_syntax_alias_match(const char *aliases, const char *lang) {
|
||
if (!aliases || !lang || !lang[0]) return false;
|
||
size_t llen = strlen(lang);
|
||
const char *p = aliases;
|
||
while (*p) {
|
||
while (*p == ' ') p++;
|
||
const char *start = p;
|
||
while (*p && *p != ' ') p++;
|
||
if ((size_t)(p - start) == llen && !strncasecmp(start, lang, llen))
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static const agent_syntax *agent_syntax_for_lang(const char *lang) {
|
||
if (lang && lang[0]) {
|
||
for (const agent_syntax *s = agent_syntaxes; s->name; s++) {
|
||
if (!strcasecmp(s->name, lang) ||
|
||
agent_syntax_alias_match(s->aliases, lang))
|
||
return s;
|
||
}
|
||
}
|
||
return &agent_syntaxes[0];
|
||
}
|
||
|
||
static const agent_syntax *agent_syntax_for_path(const char *path) {
|
||
if (!path || !path[0]) return agent_syntax_for_lang(NULL);
|
||
const char *base = strrchr(path, '/');
|
||
base = base ? base + 1 : path;
|
||
if (!strcasecmp(base, "Dockerfile")) return agent_syntax_for_lang("sh");
|
||
if (!strcasecmp(base, "Makefile") || !strcasecmp(base, "makefile"))
|
||
return agent_syntax_for_lang("sh");
|
||
const char *dot = strrchr(base, '.');
|
||
if (!dot || !dot[1]) return agent_syntax_for_lang(NULL);
|
||
return agent_syntax_for_lang(dot + 1);
|
||
}
|
||
|
||
static bool agent_syntax_separator(char c) {
|
||
unsigned char uc = (unsigned char)c;
|
||
return c == '\0' || isspace(uc) || strchr(",.()+-/*=~%[]{}<>:;!&|^?", c) != NULL;
|
||
}
|
||
|
||
static const char *agent_syntax_line_comment(const agent_syntax *syn,
|
||
const char *p) {
|
||
if (!syn) return NULL;
|
||
for (int i = 0; i < 3 && syn->singleline_comments[i]; i++) {
|
||
const char *m = syn->singleline_comments[i];
|
||
size_t mlen = strlen(m);
|
||
if (mlen && !strncmp(p, m, mlen)) return m;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static int agent_syntax_color(int hl) {
|
||
switch (hl) {
|
||
case AGENT_HL_COMMENT: return 244;
|
||
case AGENT_HL_KEYWORD1: return 214;
|
||
case AGENT_HL_KEYWORD2: return 81;
|
||
case AGENT_HL_STRING: return 150;
|
||
case AGENT_HL_NUMBER: return 203;
|
||
default: return 252;
|
||
}
|
||
}
|
||
|
||
static void renderer_syntax_write(agent_token_renderer *r, int hl,
|
||
const char *s, size_t n) {
|
||
if (!n) return;
|
||
if (hl != AGENT_HL_NORMAL) r->md_syntax_has_highlight = true;
|
||
if (r->md_syntax_silent) return;
|
||
if (r->use_color && hl != AGENT_HL_NORMAL) {
|
||
char seq[32];
|
||
snprintf(seq, sizeof(seq), "\x1b[38;5;%dm", agent_syntax_color(hl));
|
||
renderer_write(r, seq, strlen(seq));
|
||
}
|
||
renderer_write(r, s, n);
|
||
if (r->use_color && hl != AGENT_HL_NORMAL) renderer_write(r, "\x1b[0m", 4);
|
||
r->wrote_visible_output = true;
|
||
r->last_output_newline = false;
|
||
}
|
||
|
||
static void renderer_syntax_write_upto_marker(agent_token_renderer *r) {
|
||
static const char marker[] = "[upto]";
|
||
r->md_syntax_has_highlight = true;
|
||
if (r->md_syntax_silent) return;
|
||
if (r->use_color) {
|
||
renderer_write(r, "\x1b[38;5;244m[", strlen("\x1b[38;5;244m["));
|
||
renderer_write(r, "\x1b[1;38;5;177mupto",
|
||
strlen("\x1b[1;38;5;177mupto"));
|
||
renderer_write(r, "\x1b[38;5;244m]\x1b[0m",
|
||
strlen("\x1b[38;5;244m]\x1b[0m"));
|
||
} else {
|
||
renderer_write(r, marker, sizeof(marker) - 1);
|
||
}
|
||
r->wrote_visible_output = true;
|
||
r->last_output_newline = false;
|
||
}
|
||
|
||
static size_t agent_syntax_keyword_len(const char *kw, bool *secondary) {
|
||
size_t len = strlen(kw);
|
||
*secondary = len && kw[len - 1] == '|';
|
||
return *secondary ? len - 1 : len;
|
||
}
|
||
|
||
static bool agent_syntax_match_keyword(const agent_syntax *syn,
|
||
const char *p,
|
||
const char *line_end,
|
||
size_t *out_len,
|
||
int *out_hl) {
|
||
if (!syn || !syn->keywords) return false;
|
||
for (int i = 0; syn->keywords[i]; i++) {
|
||
bool secondary = false;
|
||
size_t klen = agent_syntax_keyword_len(syn->keywords[i], &secondary);
|
||
if ((size_t)(line_end - p) < klen) continue;
|
||
bool match = (syn->flags & AGENT_SYNTAX_CASE_INSENSITIVE) ?
|
||
!strncasecmp(p, syn->keywords[i], klen) :
|
||
!strncmp(p, syn->keywords[i], klen);
|
||
if (!match) continue;
|
||
if (!agent_syntax_separator(p[klen])) continue;
|
||
*out_len = klen;
|
||
*out_hl = secondary ? AGENT_HL_KEYWORD2 : AGENT_HL_KEYWORD1;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool agent_syntax_number_start(const char *p, const char *line,
|
||
bool prev_sep, int prev_hl) {
|
||
unsigned char c = (unsigned char)*p;
|
||
if (isdigit(c) && (prev_sep || prev_hl == AGENT_HL_NUMBER)) return true;
|
||
if (*p == '.' && p > line && prev_hl == AGENT_HL_NUMBER) return true;
|
||
return false;
|
||
}
|
||
|
||
static size_t agent_syntax_number_len(const char *p, const char *line_end) {
|
||
const char *q = p;
|
||
while (q < line_end) {
|
||
unsigned char c = (unsigned char)*q;
|
||
if (isalnum(c) || *q == '_' || *q == '.' || *q == '+' || *q == '-') q++;
|
||
else break;
|
||
}
|
||
return (size_t)(q - p);
|
||
}
|
||
|
||
static void renderer_syntax_emit_line(agent_token_renderer *r,
|
||
const char *line, size_t len) {
|
||
const agent_syntax *syn = r->md_syntax ? r->md_syntax : agent_syntax_for_lang(NULL);
|
||
const char *p = line;
|
||
const char *end = line + len;
|
||
bool prev_sep = true;
|
||
int prev_hl = AGENT_HL_NORMAL;
|
||
int in_string = 0;
|
||
|
||
while (p < end) {
|
||
if (r->md_code_highlight_upto &&
|
||
(size_t)(end - p) >= strlen("[upto]") &&
|
||
!strncmp(p, "[upto]", strlen("[upto]")))
|
||
{
|
||
renderer_syntax_write_upto_marker(r);
|
||
p += strlen("[upto]");
|
||
prev_sep = true;
|
||
prev_hl = AGENT_HL_NORMAL;
|
||
continue;
|
||
}
|
||
|
||
if (r->md_code_in_ml_comment) {
|
||
const char *mce = syn->multiline_end;
|
||
if (mce && *mce) {
|
||
size_t mlen = strlen(mce);
|
||
const char *q = p;
|
||
while (q < end && ((size_t)(end - q) < mlen ||
|
||
strncmp(q, mce, mlen))) q++;
|
||
if (q < end) {
|
||
q += mlen;
|
||
renderer_syntax_write(r, AGENT_HL_COMMENT, p, (size_t)(q - p));
|
||
p = q;
|
||
r->md_code_in_ml_comment = false;
|
||
prev_sep = true;
|
||
prev_hl = AGENT_HL_COMMENT;
|
||
continue;
|
||
}
|
||
}
|
||
renderer_syntax_write(r, AGENT_HL_COMMENT, p, (size_t)(end - p));
|
||
return;
|
||
}
|
||
|
||
const char *scs = agent_syntax_line_comment(syn, p);
|
||
if (!in_string && scs) {
|
||
renderer_syntax_write(r, AGENT_HL_COMMENT, p, (size_t)(end - p));
|
||
return;
|
||
}
|
||
|
||
if (!in_string && syn->multiline_start && syn->multiline_end &&
|
||
!strncmp(p, syn->multiline_start, strlen(syn->multiline_start))) {
|
||
size_t mlen = strlen(syn->multiline_start);
|
||
const char *q = p + mlen;
|
||
size_t elen = strlen(syn->multiline_end);
|
||
while (q < end && ((size_t)(end - q) < elen ||
|
||
strncmp(q, syn->multiline_end, elen))) q++;
|
||
if (q < end) q += elen;
|
||
else r->md_code_in_ml_comment = true;
|
||
renderer_syntax_write(r, AGENT_HL_COMMENT, p, (size_t)(q - p));
|
||
p = q;
|
||
prev_sep = false;
|
||
prev_hl = AGENT_HL_COMMENT;
|
||
continue;
|
||
}
|
||
|
||
if ((syn->flags & AGENT_SYNTAX_STRINGS) && in_string) {
|
||
const char *q = p;
|
||
while (q < end) {
|
||
if (*q == '\\' && q + 1 < end) {
|
||
q += 2;
|
||
continue;
|
||
}
|
||
q++;
|
||
if (q[-1] == in_string) {
|
||
in_string = 0;
|
||
break;
|
||
}
|
||
}
|
||
renderer_syntax_write(r, AGENT_HL_STRING, p, (size_t)(q - p));
|
||
p = q;
|
||
prev_sep = false;
|
||
prev_hl = AGENT_HL_STRING;
|
||
continue;
|
||
}
|
||
|
||
if ((syn->flags & AGENT_SYNTAX_STRINGS) &&
|
||
(*p == '"' || *p == '\'' ||
|
||
((syn->flags & AGENT_SYNTAX_BACKTICK_STRINGS) && *p == '`'))) {
|
||
int quote = *p;
|
||
const char *q = p + 1;
|
||
while (q < end) {
|
||
if (*q == '\\' && q + 1 < end) {
|
||
q += 2;
|
||
continue;
|
||
}
|
||
q++;
|
||
if (q[-1] == quote) {
|
||
break;
|
||
}
|
||
}
|
||
renderer_syntax_write(r, AGENT_HL_STRING, p, (size_t)(q - p));
|
||
p = q;
|
||
prev_sep = false;
|
||
prev_hl = AGENT_HL_STRING;
|
||
continue;
|
||
}
|
||
|
||
if ((syn->flags & AGENT_SYNTAX_NUMBERS) &&
|
||
agent_syntax_number_start(p, line, prev_sep, prev_hl)) {
|
||
size_t nlen = agent_syntax_number_len(p, end);
|
||
renderer_syntax_write(r, AGENT_HL_NUMBER, p, nlen);
|
||
p += nlen;
|
||
prev_sep = false;
|
||
prev_hl = AGENT_HL_NUMBER;
|
||
continue;
|
||
}
|
||
|
||
if (prev_sep) {
|
||
size_t klen = 0;
|
||
int khl = AGENT_HL_NORMAL;
|
||
if (agent_syntax_match_keyword(syn, p, end, &klen, &khl)) {
|
||
renderer_syntax_write(r, khl, p, klen);
|
||
p += klen;
|
||
prev_sep = false;
|
||
prev_hl = khl;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
renderer_syntax_write(r, AGENT_HL_NORMAL, p, 1);
|
||
prev_sep = agent_syntax_separator(*p);
|
||
prev_hl = AGENT_HL_NORMAL;
|
||
p++;
|
||
}
|
||
}
|
||
|
||
static void renderer_code_line_append(agent_token_renderer *r,
|
||
const char *s, size_t n) {
|
||
if (!n) return;
|
||
if (r->md_code_line_len + n + 1 > r->md_code_line_cap) {
|
||
size_t cap = r->md_code_line_cap ? r->md_code_line_cap * 2 : 256;
|
||
while (cap < r->md_code_line_len + n + 1) cap *= 2;
|
||
r->md_code_line = xrealloc(r->md_code_line, cap);
|
||
r->md_code_line_cap = cap;
|
||
}
|
||
memcpy(r->md_code_line + r->md_code_line_len, s, n);
|
||
r->md_code_line_len += n;
|
||
r->md_code_line[r->md_code_line_len] = '\0';
|
||
}
|
||
|
||
static int renderer_terminal_cols(void) {
|
||
struct winsize ws;
|
||
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0)
|
||
return ws.ws_col;
|
||
return 80;
|
||
}
|
||
|
||
static bool renderer_code_line_can_repaint(agent_token_renderer *r) {
|
||
if (!r->use_color || r->capture || r->md_code_line_len == 0) return false;
|
||
int cols = renderer_terminal_cols();
|
||
size_t prefix_len = r->md_code_line_prefix ?
|
||
strlen(r->md_code_line_prefix) : 0;
|
||
if (cols <= 1 || prefix_len + r->md_code_line_len >= (size_t)cols)
|
||
return false;
|
||
for (size_t i = 0; i < r->md_code_line_len; i++) {
|
||
unsigned char c = (unsigned char)r->md_code_line[i];
|
||
if (c == '\t' || c == 0x1b || c >= 0x80 || (c < 0x20 && c != '\r'))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void renderer_code_write_line_prefix(agent_token_renderer *r) {
|
||
if (!r->md_code_line_prefix) return;
|
||
if (r->use_color && r->md_code_line_prefix_color)
|
||
renderer_write(r, r->md_code_line_prefix_color,
|
||
strlen(r->md_code_line_prefix_color));
|
||
renderer_write(r, r->md_code_line_prefix,
|
||
strlen(r->md_code_line_prefix));
|
||
if (r->use_color && r->md_code_line_prefix_color)
|
||
renderer_write(r, "\x1b[0m", 4);
|
||
r->color_open = false;
|
||
}
|
||
|
||
/* Run the syntax highlighter in silent mode to learn whether the already
|
||
* streamed line would change if repainted, while preserving the multiline
|
||
* comment state until the caller decides whether repaint is safe. */
|
||
static bool renderer_code_scan_line(agent_token_renderer *r,
|
||
bool *final_ml_comment) {
|
||
bool old_silent = r->md_syntax_silent;
|
||
bool old_highlight = r->md_syntax_has_highlight;
|
||
bool old_ml_comment = r->md_code_in_ml_comment;
|
||
|
||
r->md_syntax_silent = true;
|
||
r->md_syntax_has_highlight = false;
|
||
renderer_syntax_emit_line(r, r->md_code_line, r->md_code_line_len);
|
||
bool changed = r->md_syntax_has_highlight;
|
||
*final_ml_comment = r->md_code_in_ml_comment;
|
||
|
||
r->md_code_in_ml_comment = old_ml_comment;
|
||
r->md_syntax_silent = old_silent;
|
||
r->md_syntax_has_highlight = old_highlight;
|
||
return changed;
|
||
}
|
||
|
||
/* Code is shown as soon as bytes arrive. At end-of-line we can cheaply
|
||
* replace only that terminal row with syntax-highlighted text, but only for
|
||
* simple one-row ASCII lines; long, tabbed, escaped, or UTF-8 lines are left
|
||
* as streamed and only advance the highlighter state. */
|
||
static void renderer_code_emit_buffered_line(agent_token_renderer *r,
|
||
bool with_newline) {
|
||
bool final_ml_comment = r->md_code_in_ml_comment;
|
||
bool changed = renderer_code_scan_line(r, &final_ml_comment);
|
||
bool repaint = changed && renderer_code_line_can_repaint(r);
|
||
if (repaint) {
|
||
renderer_reset_color(r);
|
||
renderer_write(r, "\r\x1b[0K", 5);
|
||
renderer_code_write_line_prefix(r);
|
||
renderer_syntax_emit_line(r, r->md_code_line, r->md_code_line_len);
|
||
} else {
|
||
r->md_code_in_ml_comment = final_ml_comment;
|
||
}
|
||
r->md_code_line_len = 0;
|
||
if (with_newline) {
|
||
renderer_write_plain_byte(r, '\n');
|
||
r->wrote_visible_output = true;
|
||
r->last_output_newline = true;
|
||
r->md_code_line_start = true;
|
||
}
|
||
}
|
||
|
||
static void renderer_code_byte(agent_token_renderer *r, char c) {
|
||
if (c == '\n') {
|
||
renderer_code_emit_buffered_line(r, true);
|
||
return;
|
||
}
|
||
renderer_code_line_append(r, &c, 1);
|
||
renderer_write_plain_byte(r, c);
|
||
if (c != ' ' && c != '\t' && c != '\r') r->md_code_line_start = false;
|
||
}
|
||
|
||
static void renderer_code_emit_backtick_literals(agent_token_renderer *r,
|
||
size_t count) {
|
||
for (size_t i = 0; i < count; i++) renderer_code_byte(r, '`');
|
||
}
|
||
|
||
static void renderer_code_begin(agent_token_renderer *r) {
|
||
renderer_reset_color(r);
|
||
r->md_code_block = true;
|
||
r->md_inline_code = false;
|
||
r->md_fence_info = true;
|
||
r->md_code_line_start = true;
|
||
r->md_code_in_ml_comment = false;
|
||
r->md_syntax = agent_syntax_for_lang(NULL);
|
||
r->md_fence_lang_len = 0;
|
||
r->md_fence_lang[0] = '\0';
|
||
r->md_code_line_prefix = NULL;
|
||
r->md_code_line_prefix_color = NULL;
|
||
r->md_code_highlight_upto = false;
|
||
r->md_code_line_len = 0;
|
||
}
|
||
|
||
static void renderer_code_stream_begin(agent_token_renderer *r,
|
||
const agent_syntax *syntax) {
|
||
renderer_reset_color(r);
|
||
r->md_code_block = true;
|
||
r->md_inline_code = false;
|
||
r->md_fence_info = false;
|
||
r->md_code_line_start = true;
|
||
r->md_code_in_ml_comment = false;
|
||
r->md_syntax = syntax ? syntax : agent_syntax_for_lang(NULL);
|
||
r->md_fence_lang_len = 0;
|
||
r->md_fence_lang[0] = '\0';
|
||
r->md_code_line_prefix = NULL;
|
||
r->md_code_line_prefix_color = NULL;
|
||
r->md_code_highlight_upto = false;
|
||
r->md_code_line_len = 0;
|
||
}
|
||
|
||
static void renderer_code_stream_set_prefix(agent_token_renderer *r,
|
||
const char *prefix,
|
||
const char *color) {
|
||
r->md_code_line_prefix = prefix;
|
||
r->md_code_line_prefix_color = color;
|
||
}
|
||
|
||
static void renderer_code_stream_set_upto_marker(agent_token_renderer *r,
|
||
bool enabled) {
|
||
r->md_code_highlight_upto = enabled;
|
||
}
|
||
|
||
static void renderer_code_end(agent_token_renderer *r) {
|
||
bool only_space = true;
|
||
for (size_t i = 0; i < r->md_code_line_len; i++) {
|
||
if (r->md_code_line[i] != ' ' && r->md_code_line[i] != '\t' &&
|
||
r->md_code_line[i] != '\r') {
|
||
only_space = false;
|
||
break;
|
||
}
|
||
}
|
||
if (r->md_code_line_len && !only_space)
|
||
renderer_code_emit_buffered_line(r, false);
|
||
else
|
||
r->md_code_line_len = 0;
|
||
r->md_code_block = false;
|
||
r->md_inline_code = false;
|
||
r->md_fence_info = false;
|
||
r->md_code_line_start = true;
|
||
r->md_code_in_ml_comment = false;
|
||
r->md_syntax = NULL;
|
||
r->md_fence_lang_len = 0;
|
||
r->md_fence_lang[0] = '\0';
|
||
r->md_code_line_prefix = NULL;
|
||
r->md_code_line_prefix_color = NULL;
|
||
}
|
||
|
||
/* Tiny streaming Markdown highlighter for assistant prose. It deliberately
|
||
* recognizes only delimiters that the model commonly emits in short answers:
|
||
* **bold**, *italic*, `inline code`, ``inline code`` and fenced code blocks.
|
||
* The state machine holds only possible delimiter bytes; once a byte is known
|
||
* to be ordinary text it is sent to the raw UTF-8 writer above. Tool
|
||
* visualization and redirected output bypass this layer. */
|
||
static void renderer_markdown_clear_pending(agent_token_renderer *r) {
|
||
r->md_pending = AGENT_MD_PENDING_NONE;
|
||
r->md_pending_len = 0;
|
||
}
|
||
|
||
static void renderer_markdown_emit_pending_literals(agent_token_renderer *r) {
|
||
char c;
|
||
if (r->md_pending == AGENT_MD_PENDING_STAR) {
|
||
c = '*';
|
||
} else if (r->md_pending == AGENT_MD_PENDING_BACKTICK) {
|
||
c = '`';
|
||
} else {
|
||
return;
|
||
}
|
||
size_t count = r->md_pending_len;
|
||
renderer_markdown_clear_pending(r);
|
||
if (r->md_code_block) {
|
||
if (c == '`') renderer_code_emit_backtick_literals(r, count);
|
||
else for (size_t i = 0; i < count; i++) renderer_code_byte(r, c);
|
||
return;
|
||
}
|
||
for (size_t i = 0; i < count; i++) renderer_write_char_raw(r, c);
|
||
}
|
||
|
||
static void renderer_markdown_commit_backticks(agent_token_renderer *r) {
|
||
size_t count = r->md_pending_len;
|
||
renderer_markdown_clear_pending(r);
|
||
if (count >= 3) {
|
||
for (size_t i = 0; i < count; i++) renderer_write_plain_byte(r, '`');
|
||
if (r->md_code_block) renderer_code_end(r);
|
||
else renderer_code_begin(r);
|
||
return;
|
||
}
|
||
if (r->md_code_block) {
|
||
renderer_code_emit_backtick_literals(r, count);
|
||
return;
|
||
}
|
||
/* Support both `code` and ``code``. The latter is uncommon in model
|
||
* replies, but accepting it costs nothing and avoids leaking delimiters. */
|
||
r->md_inline_code = !r->md_inline_code;
|
||
}
|
||
|
||
static bool renderer_space_byte(char c) {
|
||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||
}
|
||
|
||
/* Consume one byte of markdown-aware assistant output. Backticks and stars
|
||
* are held in r->pending until the parser knows whether they form a marker;
|
||
* all ordinary text is emitted with the current terminal attributes. */
|
||
static void renderer_markdown_feed(agent_token_renderer *r, char c) {
|
||
if (r->md_fence_info) {
|
||
if (c == '\n') {
|
||
if (r->md_code_block) {
|
||
r->md_fence_lang[r->md_fence_lang_len] = '\0';
|
||
r->md_syntax = agent_syntax_for_lang(r->md_fence_lang);
|
||
}
|
||
renderer_write_plain_byte(r, '\n');
|
||
r->md_fence_info = false;
|
||
} else if (r->md_code_block) {
|
||
unsigned char uc = (unsigned char)c;
|
||
if (r->md_fence_lang_len + 1 < sizeof(r->md_fence_lang) &&
|
||
(isalnum(uc) || c == '_' || c == '-' || c == '+' || c == '#'))
|
||
{
|
||
r->md_fence_lang[r->md_fence_lang_len++] = c;
|
||
}
|
||
renderer_write_plain_byte(r, c);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (r->md_pending == AGENT_MD_PENDING_BACKTICK) {
|
||
if (c == '`') {
|
||
r->md_pending_len++;
|
||
return;
|
||
}
|
||
renderer_markdown_commit_backticks(r);
|
||
renderer_markdown_feed(r, c);
|
||
return;
|
||
}
|
||
|
||
if (r->md_pending == AGENT_MD_PENDING_STAR) {
|
||
renderer_markdown_clear_pending(r);
|
||
if (!r->md_inline_code && !r->md_code_block && c == '*') {
|
||
r->md_bold = !r->md_bold;
|
||
return;
|
||
}
|
||
if (!r->md_inline_code && !r->md_code_block &&
|
||
(r->md_italic || !renderer_space_byte(c)))
|
||
{
|
||
r->md_italic = !r->md_italic;
|
||
renderer_markdown_feed(r, c);
|
||
return;
|
||
}
|
||
renderer_write_char_raw(r, '*');
|
||
renderer_markdown_feed(r, c);
|
||
return;
|
||
}
|
||
|
||
if (c == '`' && (!r->md_code_block || r->md_code_line_start)) {
|
||
r->md_pending = AGENT_MD_PENDING_BACKTICK;
|
||
r->md_pending_len = 1;
|
||
return;
|
||
}
|
||
if (r->md_code_block) {
|
||
renderer_code_byte(r, c);
|
||
return;
|
||
}
|
||
if (!r->md_inline_code && !r->md_code_block && c == '*') {
|
||
r->md_pending = AGENT_MD_PENDING_STAR;
|
||
r->md_pending_len = 1;
|
||
return;
|
||
}
|
||
renderer_write_char_raw(r, c);
|
||
}
|
||
|
||
static void renderer_markdown_finish(agent_token_renderer *r) {
|
||
/* A closing code fence can be the final bytes of the assistant reply. In
|
||
* that case no following character arrives to force the pending backticks
|
||
* through the normal streaming path, so commit a full fence here instead of
|
||
* leaking the literal ``` marker to the terminal. */
|
||
if (r->md_pending == AGENT_MD_PENDING_BACKTICK && r->md_pending_len >= 3)
|
||
renderer_markdown_commit_backticks(r);
|
||
else
|
||
renderer_markdown_emit_pending_literals(r);
|
||
if (r->md_code_block && r->md_code_line_len)
|
||
renderer_code_emit_buffered_line(r, false);
|
||
r->md_bold = false;
|
||
r->md_italic = false;
|
||
r->md_inline_code = false;
|
||
r->md_code_block = false;
|
||
r->md_fence_info = false;
|
||
r->md_code_line_start = false;
|
||
r->md_code_in_ml_comment = false;
|
||
r->md_syntax = NULL;
|
||
r->md_fence_lang_len = 0;
|
||
r->md_fence_lang[0] = '\0';
|
||
r->md_code_line_prefix = NULL;
|
||
r->md_code_line_prefix_color = NULL;
|
||
r->md_code_highlight_upto = false;
|
||
free(r->md_code_line);
|
||
r->md_code_line = NULL;
|
||
r->md_code_line_len = 0;
|
||
r->md_code_line_cap = 0;
|
||
}
|
||
|
||
static void renderer_write_char(agent_token_renderer *r, char c) {
|
||
if (!r->format_markdown || r->in_think) {
|
||
renderer_markdown_emit_pending_literals(r);
|
||
renderer_write_char_raw(r, c);
|
||
return;
|
||
}
|
||
renderer_markdown_feed(r, c);
|
||
}
|
||
|
||
/* Render assistant text while hiding <think> tags and dimming thinking text.
|
||
* The function is also responsible for not prematurely emitting a partial
|
||
* control tag split across model tokens. */
|
||
static void renderer_process(agent_token_renderer *r, const char *text, size_t len, bool finish) {
|
||
const char *think_open = "<think>";
|
||
const char *think_close = "</think>";
|
||
size_t total = r->pending_len + len;
|
||
char *buf = xmalloc(total ? total : 1);
|
||
if (r->pending_len) memcpy(buf, r->pending, r->pending_len);
|
||
if (len) memcpy(buf + r->pending_len, text, len);
|
||
r->pending_len = 0;
|
||
|
||
size_t i = 0;
|
||
while (i < total) {
|
||
const char *cur = buf + i;
|
||
size_t rem = total - i;
|
||
if (bytes_has_prefix(cur, rem, think_open)) {
|
||
r->in_think = true;
|
||
i += strlen(think_open);
|
||
continue;
|
||
}
|
||
if (bytes_has_prefix(cur, rem, think_close)) {
|
||
r->in_think = false;
|
||
renderer_reset_color(r);
|
||
if (!r->last_output_newline) renderer_write(r, "\n", 1);
|
||
renderer_write(r, "\n", 1);
|
||
r->last_output_newline = true;
|
||
i += strlen(think_close);
|
||
continue;
|
||
}
|
||
if (!finish && cur[0] == '<' &&
|
||
(bytes_is_partial_prefix(cur, rem, think_open) ||
|
||
bytes_is_partial_prefix(cur, rem, think_close)))
|
||
{
|
||
if (rem < sizeof(r->pending)) {
|
||
memcpy(r->pending, cur, rem);
|
||
r->pending_len = rem;
|
||
}
|
||
break;
|
||
}
|
||
renderer_write_char(r, cur[0]);
|
||
i++;
|
||
}
|
||
free(buf);
|
||
}
|
||
|
||
static void renderer_finish(agent_token_renderer *r) {
|
||
if (r->format_thinking) {
|
||
renderer_process(r, NULL, 0, true);
|
||
}
|
||
renderer_markdown_finish(r);
|
||
renderer_flush_utf8(r);
|
||
renderer_reset_color(r);
|
||
if (r->wrote_visible_output) {
|
||
if (!r->last_output_newline) renderer_write(r, "\n", 1);
|
||
renderer_write(r, "\n", 1);
|
||
r->last_output_newline = true;
|
||
}
|
||
}
|
||
|
||
static void renderer_color(agent_token_renderer *r, const char *seq) {
|
||
renderer_markdown_emit_pending_literals(r);
|
||
renderer_flush_utf8(r);
|
||
bool reset = !seq || !seq[0] || !strcmp(seq, "\x1b[0m");
|
||
if (r->use_color && seq && seq[0]) renderer_write(r, seq, strlen(seq));
|
||
r->color_open = r->use_color && !reset;
|
||
}
|
||
|
||
static void renderer_plain(agent_token_renderer *r, const char *s, size_t n) {
|
||
renderer_markdown_emit_pending_literals(r);
|
||
renderer_flush_utf8(r);
|
||
renderer_write(r, s, n);
|
||
if (n) r->wrote_visible_output = true;
|
||
if (n) r->last_output_newline = s[n - 1] == '\n';
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Streaming Tool Visualization
|
||
* ============================================================================
|
||
*
|
||
* Tool calls are parsed for execution later, but they are also visualized while
|
||
* the model is still sampling. This state machine suppresses raw DSML and
|
||
* prints compact, tool-specific progress such as "$ command" or
|
||
* "Reading file 1:500...".
|
||
*/
|
||
|
||
static bool streq_any(const char *s, const char *a, const char *b,
|
||
const char *c, const char *d) {
|
||
return (a && !strcmp(s, a)) || (b && !strcmp(s, b)) ||
|
||
(c && !strcmp(s, c)) || (d && !strcmp(s, d));
|
||
}
|
||
|
||
static agent_tool_param_kind agent_tool_param_kind_for(const char *tool, const char *param) {
|
||
if (!tool) tool = "";
|
||
if (!param) param = "";
|
||
if (!strcmp(tool, "bash") && !strcmp(param, "command"))
|
||
return AGENT_TOOL_PARAM_BASH_COMMAND;
|
||
if (!strcmp(tool, "edit") && !strcmp(param, "old"))
|
||
return AGENT_TOOL_PARAM_DIFF_OLD;
|
||
if (!strcmp(tool, "edit") && !strcmp(param, "new"))
|
||
return AGENT_TOOL_PARAM_DIFF_NEW;
|
||
if (streq_any(param, "path", "file", "filename", NULL))
|
||
return AGENT_TOOL_PARAM_PATH;
|
||
if (streq_any(param, "line", "start_line", "end_line", "offset") ||
|
||
streq_any(param, "start", "end", "count", "max_lines") ||
|
||
streq_any(param, "timeout_sec", "refresh_sec", NULL, NULL))
|
||
return AGENT_TOOL_PARAM_OFFSET;
|
||
if (streq_any(param, "content", "text", NULL, NULL))
|
||
return AGENT_TOOL_PARAM_CONTENT;
|
||
return AGENT_TOOL_PARAM_NORMAL;
|
||
}
|
||
|
||
static const char *agent_tool_param_color(agent_tool_param_kind kind) {
|
||
switch (kind) {
|
||
case AGENT_TOOL_PARAM_PATH: return "\x1b[32m";
|
||
case AGENT_TOOL_PARAM_OFFSET: return "\x1b[33m";
|
||
case AGENT_TOOL_PARAM_CONTENT: return "\x1b[34m";
|
||
case AGENT_TOOL_PARAM_DIFF_OLD: return "\x1b[31m";
|
||
case AGENT_TOOL_PARAM_DIFF_NEW: return "\x1b[32m";
|
||
case AGENT_TOOL_PARAM_BASH_COMMAND: return "\x1b[1;36m";
|
||
default: return "\x1b[37m";
|
||
}
|
||
}
|
||
|
||
static void agent_tool_viz_write(agent_stream_renderer *sr, const char *s, size_t n) {
|
||
renderer_plain(sr->renderer, s, n);
|
||
for (size_t i = 0; i < n; i++) sr->viz.last_output_newline = s[i] == '\n';
|
||
}
|
||
|
||
static void agent_tool_viz_puts(agent_stream_renderer *sr, const char *s) {
|
||
agent_tool_viz_write(sr, s, strlen(s));
|
||
}
|
||
|
||
static void agent_tool_viz_start(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
bool line_open = !sr->renderer->last_output_newline;
|
||
memset(v, 0, sizeof(*v));
|
||
v->active = true;
|
||
v->at_line_start = true;
|
||
v->last_output_newline = true;
|
||
if (sr->replay) {
|
||
if (line_open) agent_tool_viz_puts(sr, "\n");
|
||
} else if (sr->renderer->use_color) {
|
||
/* The raw DSML start marker may arrive after ordinary text on the
|
||
* current row. Clear that row only for the live terminal UI; plain
|
||
* stdout mode must never leak cursor-control escapes into pipes. */
|
||
agent_tool_viz_puts(sr, "\r\x1b[2K");
|
||
} else if (line_open) {
|
||
agent_tool_viz_puts(sr, "\n");
|
||
}
|
||
v->last_output_newline = true;
|
||
}
|
||
|
||
static void agent_tool_viz_line_prefix(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
agent_tool_viz_puts(sr, "🛠️ ");
|
||
v->at_line_start = false;
|
||
}
|
||
|
||
static const char *agent_tool_viz_prefix(const char *name) {
|
||
if (!strcmp(name, "bash")) return "$ ";
|
||
if (!strcmp(name, "read")) return "read ";
|
||
if (!strcmp(name, "write")) return "write ";
|
||
if (!strcmp(name, "edit")) return "edit ";
|
||
if (!strcmp(name, "search")) return "search ";
|
||
if (!strcmp(name, "google_search")) return "google ";
|
||
if (!strcmp(name, "visit_page")) return "visit ";
|
||
return NULL;
|
||
}
|
||
|
||
static void agent_tool_viz_tool(agent_stream_renderer *sr, const char *name) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (v->tool_announced && !strcmp(v->tool_name, name)) return;
|
||
if (v->tool_announced && !v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
snprintf(v->tool_name, sizeof(v->tool_name), "%s", name ? name : "tool");
|
||
v->tool_announced = true;
|
||
v->read_style = !strcmp(v->tool_name, "read");
|
||
agent_tool_viz_line_prefix(sr);
|
||
if (v->read_style) {
|
||
renderer_color(sr->renderer, "\x1b[1;37m");
|
||
agent_tool_viz_puts(sr, "Reading ");
|
||
renderer_color(sr->renderer, "\x1b[32m");
|
||
v->read_prefix_rendered = true;
|
||
return;
|
||
}
|
||
renderer_color(sr->renderer, !strcmp(v->tool_name, "bash") ?
|
||
"\x1b[1;36m" : "\x1b[1;37m");
|
||
const char *prefix = agent_tool_viz_prefix(v->tool_name);
|
||
if (prefix) {
|
||
agent_tool_viz_puts(sr, prefix);
|
||
} else {
|
||
agent_tool_viz_puts(sr, v->tool_name);
|
||
agent_tool_viz_puts(sr, " ");
|
||
}
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
}
|
||
|
||
static void agent_tool_viz_append(char *dst, size_t cap, char c) {
|
||
size_t len = strlen(dst);
|
||
if (len + 1 >= cap) return;
|
||
dst[len] = c;
|
||
dst[len + 1] = '\0';
|
||
}
|
||
|
||
static void agent_tool_viz_read_value_byte(agent_stream_renderer *sr, char c) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!strcmp(v->param_name, "path")) {
|
||
agent_tool_viz_append(v->read_path, sizeof(v->read_path), c);
|
||
if (v->read_prefix_rendered) agent_tool_viz_write(sr, &c, 1);
|
||
} else if (!strcmp(v->param_name, "start_line")) {
|
||
agent_tool_viz_append(v->read_start, sizeof(v->read_start), c);
|
||
} else if (!strcmp(v->param_name, "max_lines")) {
|
||
agent_tool_viz_append(v->read_max, sizeof(v->read_max), c);
|
||
} else if (!strcmp(v->param_name, "whole")) {
|
||
agent_tool_viz_append(v->read_whole, sizeof(v->read_whole), c);
|
||
}
|
||
}
|
||
|
||
static void agent_tool_viz_render_read(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->read_style || v->read_line_rendered) return;
|
||
|
||
if (!v->read_prefix_rendered) {
|
||
agent_tool_viz_line_prefix(sr);
|
||
renderer_color(sr->renderer, "\x1b[1;37m");
|
||
agent_tool_viz_puts(sr, "Reading ");
|
||
renderer_color(sr->renderer, "\x1b[32m");
|
||
agent_tool_viz_puts(sr, v->read_path[0] ? v->read_path : "<unknown>");
|
||
} else if (!v->read_path[0]) {
|
||
renderer_color(sr->renderer, "\x1b[32m");
|
||
agent_tool_viz_puts(sr, "<unknown>");
|
||
}
|
||
renderer_color(sr->renderer, "\x1b[33m");
|
||
bool whole = agent_parse_bool_default(v->read_whole, false);
|
||
if (whole && (!v->read_start[0] || !strcmp(v->read_start, "1"))) {
|
||
agent_tool_viz_puts(sr, " (whole file)");
|
||
} else if (whole) {
|
||
agent_tool_viz_puts(sr, " ");
|
||
agent_tool_viz_puts(sr, v->read_start);
|
||
agent_tool_viz_puts(sr, ":EOF");
|
||
} else {
|
||
agent_tool_viz_puts(sr, " ");
|
||
agent_tool_viz_puts(sr, v->read_start[0] ? v->read_start : "1");
|
||
agent_tool_viz_puts(sr, ":");
|
||
agent_tool_viz_puts(sr, v->read_max[0] ? v->read_max : "500");
|
||
}
|
||
renderer_color(sr->renderer, "\x1b[1;37m");
|
||
agent_tool_viz_puts(sr, "...");
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
agent_tool_viz_puts(sr, "\n");
|
||
v->read_line_rendered = true;
|
||
}
|
||
|
||
static bool agent_tool_viz_param_is_code_body(agent_tool_visualizer *v) {
|
||
if (!strcmp(v->tool_name, "write") &&
|
||
v->param_kind == AGENT_TOOL_PARAM_CONTENT)
|
||
return true;
|
||
if (!strcmp(v->tool_name, "edit") &&
|
||
(v->param_kind == AGENT_TOOL_PARAM_DIFF_OLD ||
|
||
v->param_kind == AGENT_TOOL_PARAM_DIFF_NEW ||
|
||
v->param_kind == AGENT_TOOL_PARAM_CONTENT))
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
static const char *agent_tool_viz_diff_prefix(agent_tool_param_kind kind,
|
||
const char **color) {
|
||
if (color) *color = NULL;
|
||
const char *prefix = NULL;
|
||
if (kind == AGENT_TOOL_PARAM_DIFF_OLD) {
|
||
prefix = "- ";
|
||
if (color) *color = "\x1b[31m";
|
||
} else if (kind == AGENT_TOOL_PARAM_DIFF_NEW) {
|
||
prefix = "+ ";
|
||
if (color) *color = "\x1b[32m";
|
||
}
|
||
return prefix;
|
||
}
|
||
|
||
static void agent_tool_viz_code_prefix(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->at_line_start) return;
|
||
const char *color = NULL;
|
||
const char *prefix = agent_tool_viz_diff_prefix(v->param_kind, &color);
|
||
if (!prefix) return;
|
||
renderer_color(sr->renderer, color);
|
||
renderer_write(sr->renderer, prefix, strlen(prefix));
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
sr->renderer->wrote_visible_output = true;
|
||
sr->renderer->last_output_newline = false;
|
||
v->last_output_newline = false;
|
||
v->at_line_start = false;
|
||
}
|
||
|
||
static void agent_tool_viz_code_begin(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
const agent_syntax *syntax = agent_syntax_for_path(v->tool_path);
|
||
renderer_code_stream_begin(sr->renderer, syntax);
|
||
renderer_code_stream_set_upto_marker(sr->renderer,
|
||
!strcmp(v->tool_name, "edit") &&
|
||
v->param_kind == AGENT_TOOL_PARAM_DIFF_OLD);
|
||
v->code_param_active = true;
|
||
if (v->param_kind == AGENT_TOOL_PARAM_DIFF_OLD ||
|
||
v->param_kind == AGENT_TOOL_PARAM_DIFF_NEW)
|
||
{
|
||
const char *color = NULL;
|
||
const char *prefix = agent_tool_viz_diff_prefix(v->param_kind, &color);
|
||
/* Diff prefixes are terminal UI, not code. Keep them outside the
|
||
* syntax buffer so a later row repaint preserves their red/green color
|
||
* while highlighting only the actual edited line. */
|
||
renderer_code_stream_set_prefix(sr->renderer, prefix, color);
|
||
agent_tool_viz_code_prefix(sr);
|
||
}
|
||
}
|
||
|
||
static void agent_tool_viz_code_end(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->code_param_active) return;
|
||
renderer_code_end(sr->renderer);
|
||
v->code_param_active = false;
|
||
v->at_line_start = true;
|
||
v->last_output_newline = sr->renderer->last_output_newline;
|
||
}
|
||
|
||
static void agent_tool_viz_code_byte(agent_stream_renderer *sr, char c) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
agent_tool_viz_code_prefix(sr);
|
||
renderer_code_byte(sr->renderer, c);
|
||
v->last_output_newline = c == '\n';
|
||
v->at_line_start = c == '\n';
|
||
}
|
||
|
||
static void agent_tool_viz_param_begin(agent_stream_renderer *sr, const char *name) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->tool_announced && sr->parser->current.name)
|
||
agent_tool_viz_tool(sr, sr->parser->current.name);
|
||
snprintf(v->param_name, sizeof(v->param_name), "%s", name ? name : "");
|
||
v->param_kind = agent_tool_param_kind_for(v->tool_name, v->param_name);
|
||
v->param_active = true;
|
||
v->param_end_len = 0;
|
||
|
||
if (v->read_style) return;
|
||
|
||
if (v->param_kind == AGENT_TOOL_PARAM_DIFF_OLD ||
|
||
v->param_kind == AGENT_TOOL_PARAM_DIFF_NEW)
|
||
{
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
v->at_line_start = true;
|
||
agent_tool_viz_code_begin(sr);
|
||
return;
|
||
}
|
||
|
||
if (v->param_kind == AGENT_TOOL_PARAM_CONTENT) {
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
if (strcmp(v->tool_name, "write")) {
|
||
renderer_color(sr->renderer, "\x1b[1;37m");
|
||
agent_tool_viz_puts(sr, v->param_name);
|
||
agent_tool_viz_puts(sr, ":\n");
|
||
}
|
||
v->at_line_start = true;
|
||
if (agent_tool_viz_param_is_code_body(v)) {
|
||
agent_tool_viz_code_begin(sr);
|
||
} else {
|
||
renderer_color(sr->renderer, "\x1b[34m");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (v->param_kind != AGENT_TOOL_PARAM_BASH_COMMAND) {
|
||
if (!v->at_line_start) agent_tool_viz_puts(sr, " ");
|
||
renderer_color(sr->renderer, "\x1b[1;37m");
|
||
agent_tool_viz_puts(sr, v->param_name);
|
||
agent_tool_viz_puts(sr, "=");
|
||
} else {
|
||
renderer_color(sr->renderer, agent_tool_param_color(AGENT_TOOL_PARAM_BASH_COMMAND));
|
||
return;
|
||
}
|
||
renderer_color(sr->renderer, agent_tool_param_color(v->param_kind));
|
||
}
|
||
|
||
static void agent_tool_viz_param_end(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
v->param_end_len = 0;
|
||
if (v->code_param_active) agent_tool_viz_code_end(sr);
|
||
if (!v->read_style) renderer_color(sr->renderer, "\x1b[0m");
|
||
v->param_active = false;
|
||
v->param_name[0] = '\0';
|
||
}
|
||
|
||
static void agent_tool_viz_param_raw_byte(agent_stream_renderer *sr, char c) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (v->read_style) {
|
||
agent_tool_viz_read_value_byte(sr, c);
|
||
return;
|
||
}
|
||
if (v->param_kind == AGENT_TOOL_PARAM_PATH) {
|
||
agent_tool_viz_append(v->tool_path, sizeof(v->tool_path), c);
|
||
}
|
||
if (v->code_param_active) {
|
||
agent_tool_viz_code_byte(sr, c);
|
||
return;
|
||
}
|
||
if (v->param_kind == AGENT_TOOL_PARAM_BASH_COMMAND) {
|
||
agent_tool_viz_write(sr, &c, 1);
|
||
v->at_line_start = c == '\n';
|
||
return;
|
||
}
|
||
if (v->param_kind == AGENT_TOOL_PARAM_DIFF_OLD ||
|
||
v->param_kind == AGENT_TOOL_PARAM_DIFF_NEW)
|
||
{
|
||
agent_tool_viz_code_begin(sr);
|
||
agent_tool_viz_code_byte(sr, c);
|
||
return;
|
||
}
|
||
agent_tool_viz_write(sr, &c, 1);
|
||
v->at_line_start = c == '\n';
|
||
}
|
||
|
||
static void agent_tool_viz_restore_param_color(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->active || !v->param_active || v->read_style) return;
|
||
renderer_color(sr->renderer, agent_tool_param_color(v->param_kind));
|
||
}
|
||
|
||
/* Stream one DSML parameter byte into the visualizer. The visualizer must not
|
||
* wait for the whole parameter: large write/edit contents should show progress
|
||
* as the model emits them, while still detecting the closing parameter tag. */
|
||
static void agent_tool_viz_param_value_byte(agent_stream_renderer *sr, char c) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
|
||
if (v->param_end_len || c == '<') {
|
||
if (v->param_end_len == sizeof(v->param_end_tail)) {
|
||
size_t keep = v->param_end_len;
|
||
v->param_end_len = 0;
|
||
for (size_t i = 0; i < keep; i++)
|
||
agent_tool_viz_param_raw_byte(sr, v->param_end_tail[i]);
|
||
if (c != '<') {
|
||
agent_tool_viz_param_raw_byte(sr, c);
|
||
return;
|
||
}
|
||
}
|
||
if (v->param_end_len < sizeof(v->param_end_tail))
|
||
v->param_end_tail[v->param_end_len++] = c;
|
||
bool complete = false;
|
||
if (agent_dsml_parameter_close_tail(v->param_end_tail, v->param_end_len, &complete)) {
|
||
if (complete) agent_tool_viz_param_end(sr);
|
||
return;
|
||
}
|
||
size_t keep = v->param_end_len;
|
||
v->param_end_len = 0;
|
||
for (size_t i = 0; i < keep; i++)
|
||
agent_tool_viz_param_raw_byte(sr, v->param_end_tail[i]);
|
||
return;
|
||
}
|
||
agent_tool_viz_param_raw_byte(sr, c);
|
||
}
|
||
|
||
static void agent_tool_viz_finish(agent_stream_renderer *sr, const char *status) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->active) return;
|
||
if (v->param_active) agent_tool_viz_param_end(sr);
|
||
if (!status || !status[0]) agent_tool_viz_render_read(sr);
|
||
if (status && status[0]) {
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
renderer_color(sr->renderer, "\x1b[90m");
|
||
agent_tool_viz_puts(sr, status);
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
}
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
v->active = false;
|
||
}
|
||
|
||
static void agent_tool_viz_dump_invalid_dsml(agent_stream_renderer *sr) {
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->active) return;
|
||
|
||
/* The normal path hides DSML and paints a friendly semantic projection. If
|
||
* parsing fails, show the exact bytes we rejected so the next fix is based
|
||
* on evidence instead of guessing from the projection. */
|
||
if (v->param_active) {
|
||
v->param_active = false;
|
||
v->param_end_len = 0;
|
||
v->param_name[0] = '\0';
|
||
}
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
renderer_color(sr->renderer, "\x1b[1;31m");
|
||
if (sr->parser->raw && sr->parser->raw_len) {
|
||
agent_tool_viz_write(sr, sr->parser->raw, sr->parser->raw_len);
|
||
} else {
|
||
agent_tool_viz_puts(sr, "<empty DSML>");
|
||
}
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
}
|
||
|
||
static void agent_stream_finish_ignored_dsml(agent_stream_renderer *sr, const char *detail) {
|
||
const char *msg =
|
||
detail && detail[0] ? detail :
|
||
"tool calling is not allowed inside <think></think>";
|
||
sr->dsml_in_think = true;
|
||
sr->dsml_in_think_reported = true;
|
||
agent_trace(sr->renderer->worker, "dsml ignored inside thinking: %s", msg);
|
||
if (!sr->renderer->last_output_newline)
|
||
renderer_plain(sr->renderer, "\n", 1);
|
||
renderer_color(sr->renderer, "\x1b[1;31m");
|
||
renderer_plain(sr->renderer, "[tool call ignored: ", 20);
|
||
renderer_plain(sr->renderer, msg, strlen(msg));
|
||
renderer_plain(sr->renderer, "]\n", 2);
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
agent_dsml_parser_reset(sr->parser);
|
||
sr->dsml_active = false;
|
||
sr->dsml_ignored = false;
|
||
}
|
||
|
||
static void agent_stream_malformed_dsml(agent_stream_renderer *sr,
|
||
const char *detail) {
|
||
const char *msg = detail && detail[0] ? detail :
|
||
"DSML markup outside a valid tool_calls block";
|
||
if (sr->parser->state == AGENT_DSML_ERROR) return;
|
||
agent_dsml_set_error(sr->parser, msg);
|
||
agent_trace(sr->renderer->worker, "malformed dsml in assistant output: %s", msg);
|
||
if (!sr->renderer->last_output_newline)
|
||
renderer_plain(sr->renderer, "\n", 1);
|
||
renderer_color(sr->renderer, "\x1b[1;31m");
|
||
renderer_plain(sr->renderer, "[invalid tool call: ", 20);
|
||
renderer_plain(sr->renderer, msg, strlen(msg));
|
||
renderer_plain(sr->renderer, "]\n", 2);
|
||
renderer_color(sr->renderer, "\x1b[0m");
|
||
}
|
||
|
||
/* Mirror parser progress into the terminal visualizer. Parser state is the
|
||
* source of truth; this function only decides what the user should see. */
|
||
static void agent_stream_tool_events(agent_stream_renderer *sr) {
|
||
agent_dsml_parser *p = sr->parser;
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!v->tool_announced && p->current.name)
|
||
agent_tool_viz_tool(sr, p->current.name);
|
||
if (v->tool_announced && !p->current.name && !v->param_active) {
|
||
agent_tool_viz_render_read(sr);
|
||
if (!v->last_output_newline) agent_tool_viz_puts(sr, "\n");
|
||
v->read_style = false;
|
||
v->read_prefix_rendered = false;
|
||
v->read_line_rendered = false;
|
||
v->read_path[0] = '\0';
|
||
v->read_start[0] = '\0';
|
||
v->read_max[0] = '\0';
|
||
v->read_whole[0] = '\0';
|
||
v->tool_announced = false;
|
||
}
|
||
if (!v->param_active && p->state == AGENT_DSML_PARAM_VALUE && p->param_name)
|
||
agent_tool_viz_param_begin(sr, p->param_name);
|
||
}
|
||
|
||
static void agent_stream_preflight_closed_param(agent_stream_renderer *sr) {
|
||
if (!sr || sr->replay || sr->dsml_ignored || sr->tool_preflight_error)
|
||
return;
|
||
agent_dsml_parser *p = sr->parser;
|
||
agent_tool_visualizer *v = &sr->viz;
|
||
if (!p || !v->param_active || strcmp(v->param_name, "old") != 0)
|
||
return;
|
||
if (!p->current.name || strcmp(p->current.name, "edit") != 0)
|
||
return;
|
||
|
||
char err[256] = {0};
|
||
if (agent_preflight_edit_old(sr->renderer->worker, &p->current,
|
||
err, sizeof(err)))
|
||
return;
|
||
|
||
sr->tool_preflight_error = true;
|
||
snprintf(sr->tool_preflight_error_msg, sizeof(sr->tool_preflight_error_msg),
|
||
"edit old selector failed before new was generated: %s",
|
||
err[0] ? err : "old text is not a unique match");
|
||
agent_trace(sr->renderer->worker, "edit old preflight failed: %s",
|
||
sr->tool_preflight_error_msg);
|
||
}
|
||
|
||
static void agent_stream_feed_dsml_byte(agent_stream_renderer *sr, char c) {
|
||
bool was_param = !sr->dsml_ignored && sr->viz.param_active;
|
||
agent_dsml_feed(sr->parser, &c, 1);
|
||
if (!sr->dsml_ignored) {
|
||
agent_stream_tool_events(sr);
|
||
if (was_param) agent_tool_viz_param_value_byte(sr, c);
|
||
if (was_param && sr->parser->state != AGENT_DSML_PARAM_VALUE &&
|
||
sr->viz.param_active)
|
||
{
|
||
agent_stream_preflight_closed_param(sr);
|
||
agent_tool_viz_param_end(sr);
|
||
}
|
||
}
|
||
if (sr->parser->state == AGENT_DSML_DONE) {
|
||
if (sr->dsml_ignored) {
|
||
agent_stream_finish_ignored_dsml(
|
||
sr, "tool calling is not allowed inside <think></think>");
|
||
} else {
|
||
agent_trace(sr->renderer->worker, "dsml done calls=%d",
|
||
sr->parser->calls.len);
|
||
agent_tool_viz_finish(sr, NULL);
|
||
sr->dsml_active = false;
|
||
}
|
||
} else if (sr->parser->state == AGENT_DSML_ERROR) {
|
||
if (sr->dsml_ignored) {
|
||
agent_stream_finish_ignored_dsml(
|
||
sr, "malformed tool call inside <think></think>");
|
||
} else {
|
||
char status[220];
|
||
snprintf(status, sizeof(status), "[invalid tool call: %s]\n",
|
||
sr->parser->error[0] ? sr->parser->error : "parse error");
|
||
agent_trace(sr->renderer->worker, "dsml error %s",
|
||
sr->parser->error[0] ? sr->parser->error : "parse error");
|
||
agent_tool_viz_dump_invalid_dsml(sr);
|
||
agent_tool_viz_finish(sr, status);
|
||
sr->dsml_active = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Start a DSML block from the streaming detector. The detector may accept a
|
||
* known malformed opening form for robustness, but the parser is seeded with
|
||
* canonical bytes so all later parsing remains strict. */
|
||
static void agent_stream_start_dsml(agent_stream_renderer *sr, bool ignored) {
|
||
sr->dsml_active = true;
|
||
sr->dsml_ignored = ignored;
|
||
if (ignored) sr->dsml_in_think = true;
|
||
sr->dsml_start_len = 0;
|
||
sr->post_think_gap = false;
|
||
agent_trace(sr->renderer->worker, "dsml start detected%s",
|
||
ignored ? " inside thinking" : "");
|
||
agent_dsml_start(sr->parser);
|
||
if (!ignored) {
|
||
agent_tool_viz_start(sr);
|
||
agent_stream_tool_events(sr);
|
||
}
|
||
}
|
||
|
||
static void agent_stream_note_plain_dsml_byte(agent_stream_renderer *sr, char c);
|
||
|
||
static void agent_stream_flush_start_tail(agent_stream_renderer *sr) {
|
||
if (!sr->dsml_start_len) return;
|
||
sr->post_think_gap = false;
|
||
for (size_t i = 0; i < sr->dsml_start_len; i++) {
|
||
renderer_write_char(sr->renderer, sr->dsml_start_tail[i]);
|
||
agent_stream_note_plain_dsml_byte(sr, sr->dsml_start_tail[i]);
|
||
if (sr->parser->state == AGENT_DSML_ERROR) break;
|
||
}
|
||
sr->dsml_start_len = 0;
|
||
}
|
||
|
||
static bool agent_stream_dsml_start_match(const char *tail, size_t len,
|
||
bool *complete,
|
||
bool *implicit_invoke) {
|
||
static const char canonical[] = "<|DSML|tool_calls>";
|
||
static const char missing_bar[] = "<DSML|tool_calls>";
|
||
static const char invoke[] = "<|DSML|invoke";
|
||
static const char invoke_missing_bar[] = "<DSML|invoke";
|
||
struct {
|
||
const char *text;
|
||
bool implicit_invoke;
|
||
} forms[] = {
|
||
{canonical, false},
|
||
{missing_bar, false},
|
||
{invoke, true},
|
||
{invoke_missing_bar, true},
|
||
};
|
||
*complete = false;
|
||
*implicit_invoke = false;
|
||
for (size_t i = 0; i < sizeof(forms)/sizeof(forms[0]); i++) {
|
||
size_t form_len = strlen(forms[i].text);
|
||
if (len <= form_len && memcmp(forms[i].text, tail, len) == 0) {
|
||
*complete = len == form_len;
|
||
*implicit_invoke = forms[i].implicit_invoke;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool agent_tail_matches(const char *tail, size_t len,
|
||
const char *needle, size_t needle_len) {
|
||
return len >= needle_len &&
|
||
memcmp(tail + len - needle_len, needle, needle_len) == 0;
|
||
}
|
||
|
||
/* Detect DSML-looking control markers in text that is not currently owned by
|
||
* the executable DSML parser. This helper intentionally has no policy: inside
|
||
* <think> the marker means "tool call attempted too early", while in normal
|
||
* assistant output it means malformed DSML that the model should see as a tool
|
||
* error. */
|
||
static bool agent_dsml_marker_detector_feed(agent_dsml_marker_detector *d,
|
||
char c) {
|
||
if (d->len == sizeof(d->tail)) {
|
||
memmove(d->tail, d->tail + 1, sizeof(d->tail) - 1);
|
||
d->len--;
|
||
}
|
||
d->tail[d->len++] = c;
|
||
|
||
static const char fullwidth_marker[] = "|DSML|";
|
||
static const char ascii_marker[] = "|DSML|";
|
||
static const char missing_open[] = "<DSML|";
|
||
static const char missing_close[] = "</DSML|";
|
||
return agent_tail_matches(d->tail, d->len,
|
||
fullwidth_marker, sizeof(fullwidth_marker) - 1) ||
|
||
agent_tail_matches(d->tail, d->len,
|
||
ascii_marker, sizeof(ascii_marker) - 1) ||
|
||
agent_tail_matches(d->tail, d->len,
|
||
missing_open, sizeof(missing_open) - 1) ||
|
||
agent_tail_matches(d->tail, d->len,
|
||
missing_close, sizeof(missing_close) - 1);
|
||
}
|
||
|
||
static void agent_stream_note_thinking_dsml_byte(agent_stream_renderer *sr,
|
||
char c) {
|
||
if (!sr->in_think || sr->dsml_in_think) return;
|
||
if (agent_dsml_marker_detector_feed(&sr->think_dsml, c))
|
||
sr->dsml_in_think = true;
|
||
}
|
||
|
||
static void agent_stream_note_plain_dsml_byte(agent_stream_renderer *sr,
|
||
char c) {
|
||
if (sr->parser->state == AGENT_DSML_ERROR) return;
|
||
if (sr->dsml_active || sr->in_think || sr->dsml_in_think) return;
|
||
if (agent_dsml_marker_detector_feed(&sr->plain_dsml, c)) {
|
||
agent_stream_malformed_dsml(
|
||
sr, "DSML markup outside a valid tool_calls block");
|
||
}
|
||
}
|
||
|
||
/* Route ordinary assistant bytes either to normal markdown rendering or into
|
||
* the DSML detector. The detector must hold short prefixes because the model
|
||
* can split "<|DSML|tool_calls>" across arbitrary tokens. */
|
||
static void agent_stream_normal_byte(agent_stream_renderer *sr, char c) {
|
||
static const char start[] = "<|DSML|tool_calls>";
|
||
static const char canonical_invoke[] = "<|DSML|invoke";
|
||
if (sr->parser->state == AGENT_DSML_ERROR) return;
|
||
agent_stream_note_thinking_dsml_byte(sr, c);
|
||
|
||
/* DeepSeek usually emits one or more blank lines after </think> before
|
||
* either prose or a DSML tool stanza. At that point the bytes are just a
|
||
* visual gap between the hidden thinking phase and the real answer, and
|
||
* printing them makes tool calls appear after odd empty lines. We only
|
||
* suppress whitespace in this very narrow post-thinking window; once the
|
||
* first non-space byte arrives, normal rendering resumes. */
|
||
if (sr->post_think_gap &&
|
||
(c == ' ' || c == '\t' || c == '\r' || c == '\n'))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (sr->dsml_start_len || c == start[0]) {
|
||
if (sr->dsml_start_len < sizeof(sr->dsml_start_tail))
|
||
sr->dsml_start_tail[sr->dsml_start_len++] = c;
|
||
bool complete = false, implicit_invoke = false;
|
||
if (agent_stream_dsml_start_match(sr->dsml_start_tail, sr->dsml_start_len,
|
||
&complete, &implicit_invoke))
|
||
{
|
||
if (complete) {
|
||
/* Accept the common missing-leading-bar typo
|
||
* "<DSML|tool_calls>" here, but seed the parser with the
|
||
* canonical marker so the rest of the DSML parser stays
|
||
* strict and simple. Also accept a direct invoke opener as an
|
||
* implicit tool_calls block; the model often knows it wants a
|
||
* tool but forgets the outer wrapper. */
|
||
agent_stream_start_dsml(sr, sr->in_think);
|
||
if (implicit_invoke) {
|
||
for (size_t i = 0; i < sizeof(canonical_invoke) - 1; i++)
|
||
agent_stream_feed_dsml_byte(sr, canonical_invoke[i]);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (sr->dsml_start_len > 1 &&
|
||
sr->dsml_start_tail[sr->dsml_start_len - 1] == start[0])
|
||
{
|
||
sr->post_think_gap = false;
|
||
size_t flush = sr->dsml_start_len - 1;
|
||
for (size_t i = 0; i < flush; i++) {
|
||
renderer_write_char(sr->renderer, sr->dsml_start_tail[i]);
|
||
agent_stream_note_plain_dsml_byte(sr, sr->dsml_start_tail[i]);
|
||
if (sr->parser->state == AGENT_DSML_ERROR) break;
|
||
}
|
||
if (sr->parser->state == AGENT_DSML_ERROR) {
|
||
sr->dsml_start_len = 0;
|
||
return;
|
||
}
|
||
sr->dsml_start_tail[0] = start[0];
|
||
sr->dsml_start_len = 1;
|
||
return;
|
||
}
|
||
agent_stream_flush_start_tail(sr);
|
||
return;
|
||
}
|
||
|
||
sr->post_think_gap = false;
|
||
renderer_write_char(sr->renderer, c);
|
||
agent_stream_note_plain_dsml_byte(sr, c);
|
||
}
|
||
|
||
/* This is the single streaming display state machine for assistant output. It
|
||
* hides raw DSML as soon as the tool_calls marker is complete, lets the DSML
|
||
* parser continue building executable calls, and paints semantic tool output
|
||
* from parser state changes. The sampled transcript remains unchanged: only
|
||
* the terminal projection is rewritten. */
|
||
static void agent_stream_text(agent_stream_renderer *sr, const char *text, size_t len, bool finish) {
|
||
const char *think_open = "<think>";
|
||
const char *think_close = "</think>";
|
||
size_t total = sr->pending_len + len;
|
||
char *buf = xmalloc(total ? total : 1);
|
||
if (sr->pending_len) memcpy(buf, sr->pending, sr->pending_len);
|
||
if (len) memcpy(buf + sr->pending_len, text, len);
|
||
sr->pending_len = 0;
|
||
|
||
/* The UI may reset terminal attributes while redrawing the editable prompt
|
||
* between generated chunks. If a DSML parameter is still streaming, make
|
||
* each new token fragment self-contained by restoring the active parameter
|
||
* color before visible bytes are projected. This keeps the prompt normal
|
||
* without sacrificing long write/edit content coloring. */
|
||
if (len) agent_tool_viz_restore_param_color(sr);
|
||
if (len && !sr->dsml_active) renderer_restore_text_attrs(sr->renderer);
|
||
|
||
size_t i = 0;
|
||
while (i < total) {
|
||
char *cur = buf + i;
|
||
size_t rem = total - i;
|
||
if (!sr->dsml_active && bytes_has_prefix(cur, rem, think_open)) {
|
||
agent_stream_flush_start_tail(sr);
|
||
sr->post_think_gap = false;
|
||
sr->in_think = true;
|
||
sr->renderer->in_think = true;
|
||
i += strlen(think_open);
|
||
continue;
|
||
}
|
||
if (!sr->dsml_active && bytes_has_prefix(cur, rem, think_close)) {
|
||
agent_stream_flush_start_tail(sr);
|
||
sr->in_think = false;
|
||
sr->renderer->in_think = false;
|
||
renderer_reset_color(sr->renderer);
|
||
if (!sr->renderer->last_output_newline)
|
||
renderer_write(sr->renderer, "\n", 1);
|
||
renderer_write(sr->renderer, "\n", 1);
|
||
sr->renderer->last_output_newline = true;
|
||
sr->post_think_gap = true;
|
||
i += strlen(think_close);
|
||
continue;
|
||
}
|
||
if (!finish && !sr->dsml_active && cur[0] == '<' &&
|
||
(bytes_is_partial_prefix(cur, rem, think_open) ||
|
||
bytes_is_partial_prefix(cur, rem, think_close)))
|
||
{
|
||
if (rem < sizeof(sr->pending)) {
|
||
memcpy(sr->pending, cur, rem);
|
||
sr->pending_len = rem;
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (sr->dsml_active) {
|
||
agent_stream_feed_dsml_byte(sr, cur[0]);
|
||
} else if (sr->in_think) {
|
||
/* Tool calls are executable only after thinking has closed. Still
|
||
* route thinking bytes through the DSML start detector so an
|
||
* accidental in-think tool stanza can be suppressed cleanly instead
|
||
* of being shown as raw markup or, worse, executed. */
|
||
agent_stream_normal_byte(sr, cur[0]);
|
||
} else {
|
||
agent_stream_normal_byte(sr, cur[0]);
|
||
}
|
||
i++;
|
||
}
|
||
free(buf);
|
||
|
||
if (finish) {
|
||
agent_stream_flush_start_tail(sr);
|
||
sr->post_think_gap = false;
|
||
if (sr->dsml_active) {
|
||
if (sr->dsml_ignored) {
|
||
agent_stream_finish_ignored_dsml(
|
||
sr, "tool calling is not allowed inside <think></think>");
|
||
} else {
|
||
agent_tool_viz_finish(sr, sr->tool_preflight_error ?
|
||
"[tool call stopped: edit old selector failed]\n" :
|
||
"[tool call interrupted]\n");
|
||
sr->dsml_active = false;
|
||
}
|
||
}
|
||
if (sr->dsml_in_think && !sr->dsml_in_think_reported) {
|
||
agent_stream_finish_ignored_dsml(
|
||
sr, "tool calling is not allowed inside <think></think>");
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Worker Progress And Generic Buffers
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void worker_progress_cb(void *ud, const char *event, int current, int total) {
|
||
(void)total;
|
||
agent_worker *w = ud;
|
||
if (!w || !event) return;
|
||
if (strcmp(event, "prefill_chunk") && strcmp(event, "prefill_display")) return;
|
||
worker_apply_pending_power(w);
|
||
pthread_mutex_lock(&w->mu);
|
||
int done = current - w->progress_base;
|
||
if (done < 0) done = 0;
|
||
if (done > w->status.prefill_total) done = w->status.prefill_total;
|
||
w->status.prefill_done = done;
|
||
double elapsed = now_sec() - w->progress_started_at;
|
||
w->status.prefill_tps =
|
||
done > 0 && elapsed > 0.0 ? (double)done / elapsed : 0.0;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static bool worker_should_interrupt(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool interrupt = w->interrupt || w->stop;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return interrupt;
|
||
}
|
||
|
||
/* Ctrl+C is a latched request consumed by the worker. Once an interrupted
|
||
* operation has reached a stable append-only boundary and is about to publish
|
||
* IDLE, the request must be acknowledged; otherwise the editor can observe an
|
||
* idle worker with a stale interrupt still pending. */
|
||
static void worker_clear_interrupt(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->interrupt = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static bool agent_err_is_interrupted(const char *err) {
|
||
return err && !strcmp(err, "interrupted");
|
||
}
|
||
|
||
static bool worker_cancel_session_cb(void *ud) {
|
||
return worker_should_interrupt(ud);
|
||
}
|
||
|
||
typedef struct {
|
||
char *ptr;
|
||
size_t len;
|
||
size_t cap;
|
||
bool truncated;
|
||
} agent_buf;
|
||
|
||
static void agent_buf_append(agent_buf *b, const char *s, size_t n) {
|
||
if (!n || b->truncated) return;
|
||
const size_t max = 128 * 1024;
|
||
if (b->len + n > max) {
|
||
n = max > b->len ? max - b->len : 0;
|
||
b->truncated = true;
|
||
}
|
||
if (!n) return;
|
||
if (b->len + n + 1 > b->cap) {
|
||
size_t cap = b->cap ? b->cap * 2 : 4096;
|
||
while (cap < b->len + n + 1) cap *= 2;
|
||
b->ptr = xrealloc(b->ptr, cap);
|
||
b->cap = cap;
|
||
}
|
||
memcpy(b->ptr + b->len, s, n);
|
||
b->len += n;
|
||
b->ptr[b->len] = '\0';
|
||
}
|
||
|
||
static void agent_buf_puts(agent_buf *b, const char *s) {
|
||
agent_buf_append(b, s, strlen(s));
|
||
}
|
||
|
||
static char *agent_buf_take(agent_buf *b) {
|
||
if (!b->ptr) return xstrdup("");
|
||
char *p = b->ptr;
|
||
memset(b, 0, sizeof(*b));
|
||
return p;
|
||
}
|
||
|
||
static bool agent_tokens_equal(const ds4_tokens *a, const ds4_tokens *b) {
|
||
if (!a || !b || a->len != b->len) return false;
|
||
for (int i = 0; i < a->len; i++) {
|
||
if (a->v[i] != b->v[i]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static bool agent_mkdir_p(const char *path) {
|
||
if (!path || !path[0]) return false;
|
||
char *tmp = xstrdup(path);
|
||
for (char *p = tmp + 1; *p; p++) {
|
||
if (*p != '/') continue;
|
||
*p = '\0';
|
||
if (mkdir(tmp, 0700) != 0 && errno != EEXIST) {
|
||
free(tmp);
|
||
return false;
|
||
}
|
||
*p = '/';
|
||
}
|
||
bool ok = mkdir(tmp, 0700) == 0 || errno == EEXIST;
|
||
free(tmp);
|
||
return ok;
|
||
}
|
||
|
||
static char *agent_default_cache_dir(void) {
|
||
const char *home = getenv("HOME");
|
||
if (!home || !home[0]) home = ".";
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, home);
|
||
if (b.len == 0 || b.ptr[b.len - 1] != '/') agent_buf_puts(&b, "/");
|
||
agent_buf_puts(&b, ".ds4/kvcache");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static char *agent_kv_path_for_sha(const char *dir, const char sha[41]) {
|
||
char name[44];
|
||
memcpy(name, sha, 40);
|
||
memcpy(name + 40, ".kv", 4);
|
||
return ds4_kvstore_path_join(dir, name);
|
||
}
|
||
|
||
static void agent_le_put64(uint8_t *p, uint64_t v) {
|
||
for (int i = 0; i < 8; i++) p[i] = (uint8_t)(v >> (8 * i));
|
||
}
|
||
|
||
/* Agent session IDs are intentionally independent from the rendered transcript:
|
||
* once a session has a title and creation time, resaving it keeps the same file
|
||
* name while the transcript and KV payload evolve. */
|
||
static void agent_session_identity_sha(const char *title, uint64_t created_at,
|
||
char sha_out[41]) {
|
||
size_t title_len = title ? strlen(title) : 0;
|
||
agent_buf b = {0};
|
||
agent_buf_append(&b, title ? title : "", title_len);
|
||
uint8_t ts[8];
|
||
agent_le_put64(ts, created_at);
|
||
agent_buf_append(&b, (const char *)ts, sizeof(ts));
|
||
ds4_kvstore_sha1_bytes_hex(b.ptr ? b.ptr : "", b.len, sha_out);
|
||
free(b.ptr);
|
||
}
|
||
|
||
static void agent_worker_clear_session_identity(agent_worker *w) {
|
||
w->session_sha[0] = '\0';
|
||
free(w->session_title);
|
||
w->session_title = NULL;
|
||
w->session_created_at = 0;
|
||
free(w->legacy_session_path_to_delete);
|
||
w->legacy_session_path_to_delete = NULL;
|
||
}
|
||
|
||
typedef struct {
|
||
bool has_title_trailer;
|
||
bool legacy_identity;
|
||
char *title;
|
||
uint64_t created_at;
|
||
char sha[41];
|
||
} agent_kv_session_meta;
|
||
|
||
static void agent_kv_session_meta_free(agent_kv_session_meta *m) {
|
||
free(m->title);
|
||
memset(m, 0, sizeof(*m));
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Agent KV Store And Session Persistence
|
||
* ============================================================================
|
||
*/
|
||
|
||
static char *agent_session_title_from_text(const char *text, size_t text_len,
|
||
size_t max_bytes);
|
||
|
||
/* Agent sessions deliberately use a different policy from ds4-server:
|
||
*
|
||
* - sysprompt.kv is a fixed bootstrap checkpoint for the current tool/system
|
||
* prompt. Because its name is fixed, the current rendered text is compared
|
||
* with the text stored in the file before loading. A mismatch simply rebuilds
|
||
* and overwrites the file.
|
||
* - conversation sessions are explicit saves only. Their stable file name is
|
||
* SHA1(title || created_at_le64).kv, where title is the first user prompt and
|
||
* created_at is preserved across future saves. The title is stored in an
|
||
* agent-only trailer after the KV payload.
|
||
*
|
||
* The DS4 payload stores the exact token sequence and graph state. The rendered
|
||
* text is retained for listing, history rendering, and stripped-session rebuilds. */
|
||
static bool agent_kv_read_text(FILE *fp, uint32_t text_bytes,
|
||
char **text_out, char *err, size_t err_len) {
|
||
char *text = xmalloc((size_t)text_bytes + 1);
|
||
if (fread(text, 1, text_bytes, fp) != text_bytes) {
|
||
if (err && err_len) snprintf(err, err_len, "truncated cached text");
|
||
free(text);
|
||
return false;
|
||
}
|
||
text[text_bytes] = '\0';
|
||
*text_out = text;
|
||
return true;
|
||
}
|
||
|
||
static bool agent_kv_write_title_trailer(FILE *fp, const char *title,
|
||
char *err, size_t err_len) {
|
||
size_t title_len = title ? strlen(title) : 0;
|
||
if (title_len > UINT32_MAX) {
|
||
snprintf(err, err_len, "agent session title is too large");
|
||
return false;
|
||
}
|
||
uint8_t tb[4];
|
||
ds4_kvstore_le_put32(tb, (uint32_t)title_len);
|
||
return fwrite(tb, 1, sizeof(tb), fp) == sizeof(tb) &&
|
||
fwrite(title ? title : "", 1, title_len, fp) == title_len;
|
||
}
|
||
|
||
/* Read the optional agent title trailer without disturbing the payload cursor.
|
||
* The caller is positioned just after rendered text, which is also the payload
|
||
* start expected by ds4_session_load_payload(). */
|
||
static bool agent_kv_read_title_trailer(FILE *fp, const ds4_kvstore_entry *hdr,
|
||
char **title_out,
|
||
char *err, size_t err_len) {
|
||
off_t payload_pos = ftello(fp);
|
||
if (payload_pos < 0) {
|
||
if (err && err_len) snprintf(err, err_len, "%s", strerror(errno));
|
||
return false;
|
||
}
|
||
if (hdr->payload_bytes > (uint64_t)LLONG_MAX ||
|
||
fseeko(fp, (off_t)hdr->payload_bytes, SEEK_CUR) != 0)
|
||
{
|
||
if (err && err_len) snprintf(err, err_len, "%s", strerror(errno));
|
||
return false;
|
||
}
|
||
|
||
uint8_t tb[4];
|
||
if (fread(tb, 1, sizeof(tb), fp) != sizeof(tb)) {
|
||
if (err && err_len) snprintf(err, err_len, "missing agent session title trailer");
|
||
fseeko(fp, payload_pos, SEEK_SET);
|
||
return false;
|
||
}
|
||
uint32_t title_bytes = ds4_kvstore_le_get32(tb);
|
||
char *title = xmalloc((size_t)title_bytes + 1);
|
||
if (fread(title, 1, title_bytes, fp) != title_bytes) {
|
||
if (err && err_len) snprintf(err, err_len, "truncated agent session title trailer");
|
||
free(title);
|
||
fseeko(fp, payload_pos, SEEK_SET);
|
||
return false;
|
||
}
|
||
title[title_bytes] = '\0';
|
||
if (fseeko(fp, payload_pos, SEEK_SET) != 0) {
|
||
if (err && err_len) snprintf(err, err_len, "%s", strerror(errno));
|
||
free(title);
|
||
return false;
|
||
}
|
||
*title_out = title;
|
||
return true;
|
||
}
|
||
|
||
static void agent_kv_identity_sha(const ds4_kvstore_entry *hdr,
|
||
const char *text, uint32_t text_bytes,
|
||
const char *title,
|
||
char sha_out[41]) {
|
||
if (hdr->ext_flags & DS4_KVSTORE_EXT_SESSION_TITLE) {
|
||
agent_session_identity_sha(title ? title : "", hdr->created_at, sha_out);
|
||
} else {
|
||
ds4_kvstore_sha1_bytes_hex(text, text_bytes, sha_out);
|
||
}
|
||
}
|
||
|
||
/* Load a KV file and optionally verify either its session identity or exact
|
||
* rendered text. sysprompt.kv uses exact text because the file name is fixed;
|
||
* saved sessions use their filename SHA: modern agent sessions hash the title
|
||
* trailer plus created_at, while legacy sessions still hash rendered text. */
|
||
static bool agent_kv_load_path(agent_worker *w, const char *path,
|
||
const char *expected_sha,
|
||
const char *expected_text,
|
||
size_t expected_text_len,
|
||
ds4_tokens *loaded_tokens,
|
||
agent_kv_session_meta *meta_out,
|
||
char *err, size_t err_len) {
|
||
FILE *fp = fopen(path, "rb");
|
||
if (!fp) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
return false;
|
||
}
|
||
|
||
ds4_kvstore_entry hdr = {0};
|
||
uint32_t text_bytes = 0;
|
||
bool ok = ds4_kvstore_read_header(fp, &hdr, &text_bytes);
|
||
if (!ok) snprintf(err, err_len, "invalid KV header");
|
||
|
||
char *text = NULL;
|
||
if (ok) ok = agent_kv_read_text(fp, text_bytes, &text, err, err_len);
|
||
char *title = NULL;
|
||
bool has_title = ok && (hdr.ext_flags & DS4_KVSTORE_EXT_SESSION_TITLE);
|
||
if (has_title)
|
||
ok = agent_kv_read_title_trailer(fp, &hdr, &title, err, err_len);
|
||
uint32_t expected_tokens = hdr.tokens;
|
||
if (ok && hdr.payload_bytes != 0 &&
|
||
hdr.model_id != (uint8_t)ds4_engine_model_id(w->engine))
|
||
{
|
||
snprintf(err, err_len, "KV checkpoint was written for a different model");
|
||
ok = false;
|
||
}
|
||
if (ok && hdr.payload_bytes != 0 &&
|
||
hdr.quant_bits != (uint8_t)ds4_engine_routed_quant_bits(w->engine))
|
||
{
|
||
snprintf(err, err_len, "KV checkpoint was written for a different quantization");
|
||
ok = false;
|
||
}
|
||
if (ok && expected_text) {
|
||
if ((size_t)text_bytes != expected_text_len ||
|
||
memcmp(text, expected_text, expected_text_len) != 0)
|
||
{
|
||
snprintf(err, err_len, "cached text does not match current system prompt");
|
||
ok = false;
|
||
}
|
||
}
|
||
if (ok && expected_sha) {
|
||
char actual_sha[41];
|
||
agent_kv_identity_sha(&hdr, text, text_bytes, title, actual_sha);
|
||
if (strcmp(actual_sha, expected_sha)) {
|
||
snprintf(err, err_len, "cached session identity does not match file name");
|
||
ok = false;
|
||
}
|
||
}
|
||
|
||
char load_err[160] = {0};
|
||
if (ok && hdr.payload_bytes == 0) {
|
||
ds4_tokens rebuilt = {0};
|
||
ds4_tokenize_rendered_chat(w->engine, text, &rebuilt);
|
||
expected_tokens = (uint32_t)rebuilt.len;
|
||
if (agent_worker_sync_tokens(w, &rebuilt, true, err, err_len) != 0) {
|
||
ds4_session_invalidate(w->session);
|
||
ok = false;
|
||
}
|
||
ds4_tokens_free(&rebuilt);
|
||
} else if (ok &&
|
||
ds4_session_load_payload(w->session, fp, hdr.payload_bytes,
|
||
load_err, sizeof(load_err)) != 0)
|
||
{
|
||
snprintf(err, err_len, "%s", load_err[0] ? load_err : "failed to load KV payload");
|
||
ds4_session_invalidate(w->session);
|
||
ok = false;
|
||
}
|
||
fclose(fp);
|
||
|
||
if (ok) {
|
||
const ds4_tokens *live = ds4_session_tokens(w->session);
|
||
if (!live || live->len != (int)expected_tokens) {
|
||
snprintf(err, err_len, "KV payload token count mismatch");
|
||
ds4_session_invalidate(w->session);
|
||
ok = false;
|
||
} else if (loaded_tokens) {
|
||
ds4_tokens_free(loaded_tokens);
|
||
ds4_tokens_copy(loaded_tokens, live);
|
||
}
|
||
if (meta_out) {
|
||
agent_kv_session_meta_free(meta_out);
|
||
meta_out->has_title_trailer = has_title;
|
||
meta_out->legacy_identity = !has_title;
|
||
meta_out->created_at = hdr.created_at;
|
||
agent_kv_identity_sha(&hdr, text, text_bytes, title, meta_out->sha);
|
||
meta_out->title = has_title ?
|
||
xstrdup(title) :
|
||
agent_session_title_from_text(text, text_bytes, 0);
|
||
}
|
||
}
|
||
free(title);
|
||
free(text);
|
||
return ok;
|
||
}
|
||
|
||
/* Save the current live KV under the rendered transcript identity. The caller
|
||
* decides the policy: fixed sysprompt path or SHA-named session path. */
|
||
static bool agent_kv_save_path(agent_worker *w, const char *path,
|
||
const ds4_tokens *tokens,
|
||
const char *reason,
|
||
char sha_out[41],
|
||
const char *session_title,
|
||
uint64_t session_created_at,
|
||
char *err, size_t err_len) {
|
||
const ds4_tokens *live = ds4_session_tokens(w->session);
|
||
if (!agent_tokens_equal(live, tokens)) {
|
||
snprintf(err, err_len, "live KV state does not match session transcript");
|
||
return false;
|
||
}
|
||
const int quant_bits = ds4_engine_routed_quant_bits(w->engine);
|
||
if (quant_bits != 2 && quant_bits != 4) {
|
||
snprintf(err, err_len, "unsupported routed quantization for KV save");
|
||
return false;
|
||
}
|
||
const int model_id = ds4_engine_model_id(w->engine);
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_kvstore_render_tokens_text(w->engine, tokens, &text_len);
|
||
if (!text) {
|
||
snprintf(err, err_len, "failed to render KV text key");
|
||
return false;
|
||
}
|
||
if (text_len > UINT32_MAX) {
|
||
snprintf(err, err_len, "rendered KV text key is too large");
|
||
free(text);
|
||
return false;
|
||
}
|
||
const bool session_identity = session_title != NULL;
|
||
uint64_t now = (uint64_t)time(NULL);
|
||
uint64_t created_at = session_identity && session_created_at ?
|
||
session_created_at : now;
|
||
char sha[41];
|
||
if (session_identity)
|
||
agent_session_identity_sha(session_title, created_at, sha);
|
||
else
|
||
ds4_kvstore_sha1_bytes_hex(text, text_len, sha);
|
||
if (sha_out) memcpy(sha_out, sha, sizeof(sha));
|
||
|
||
ds4_session_payload_file staged = {0};
|
||
char save_err[160] = {0};
|
||
if (ds4_session_stage_payload(w->session, &staged,
|
||
save_err, sizeof(save_err)) != 0) {
|
||
snprintf(err, err_len, "%s",
|
||
save_err[0] ? save_err : "session has no valid KV payload");
|
||
free(text);
|
||
return false;
|
||
}
|
||
uint64_t payload_bytes = staged.bytes;
|
||
|
||
agent_buf tmpl = {0};
|
||
agent_buf_puts(&tmpl, path);
|
||
agent_buf_puts(&tmpl, ".tmp.XXXXXX");
|
||
char *tmp = agent_buf_take(&tmpl);
|
||
int fd = mkstemp(tmp);
|
||
if (fd < 0) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
ds4_session_payload_file_free(&staged);
|
||
free(tmp);
|
||
free(text);
|
||
return false;
|
||
}
|
||
|
||
FILE *fp = fdopen(fd, "wb");
|
||
if (!fp) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
close(fd);
|
||
unlink(tmp);
|
||
ds4_session_payload_file_free(&staged);
|
||
free(tmp);
|
||
free(text);
|
||
return false;
|
||
}
|
||
|
||
uint8_t h[DS4_KVSTORE_FIXED_HEADER];
|
||
ds4_kvstore_fill_header(h, (uint8_t)model_id, (uint8_t)quant_bits,
|
||
ds4_kvstore_reason_code(reason),
|
||
session_identity ? DS4_KVSTORE_EXT_SESSION_TITLE : 0,
|
||
(uint32_t)tokens->len, 0,
|
||
(uint32_t)ds4_session_ctx(w->session),
|
||
created_at, now, payload_bytes);
|
||
uint8_t tb[4];
|
||
ds4_kvstore_le_put32(tb, (uint32_t)text_len);
|
||
|
||
errno = 0;
|
||
bool ok = fwrite(h, 1, sizeof(h), fp) == sizeof(h) &&
|
||
fwrite(tb, 1, sizeof(tb), fp) == sizeof(tb) &&
|
||
fwrite(text, 1, text_len, fp) == text_len &&
|
||
ds4_session_write_staged_payload(&staged, fp,
|
||
save_err, sizeof(save_err)) == 0 &&
|
||
(!session_identity ||
|
||
agent_kv_write_title_trailer(fp, session_title,
|
||
save_err, sizeof(save_err))) &&
|
||
fflush(fp) == 0;
|
||
int saved_errno = errno;
|
||
if (fclose(fp) != 0) {
|
||
if (!saved_errno) saved_errno = errno;
|
||
ok = false;
|
||
}
|
||
if (ok && rename(tmp, path) != 0) {
|
||
saved_errno = errno;
|
||
ok = false;
|
||
}
|
||
if (!ok) {
|
||
snprintf(err, err_len, "%s",
|
||
saved_errno ? strerror(saved_errno) :
|
||
(save_err[0] ? save_err : "failed to write KV file"));
|
||
unlink(tmp);
|
||
}
|
||
|
||
ds4_session_payload_file_free(&staged);
|
||
free(tmp);
|
||
free(text);
|
||
return ok;
|
||
}
|
||
|
||
static void agent_worker_build_system_tokens(agent_worker *w, ds4_tokens *out) {
|
||
ds4_chat_begin(w->engine, out);
|
||
if (w->cfg->gen.think_mode == DS4_THINK_MAX &&
|
||
effective_think_mode(w->cfg) == DS4_THINK_MAX)
|
||
ds4_chat_append_max_effort_prefix(w->engine, out);
|
||
agent_append_system_prompt(w->engine, out, w->cfg->gen.system);
|
||
}
|
||
|
||
static void agent_publish_system_status(agent_worker *w, const char *msg) {
|
||
if (w->cfg->non_interactive) return;
|
||
if (isatty(STDOUT_FILENO)) {
|
||
static const char marker[] = "\x1b[33m✦ \x1b[38;5;218m";
|
||
agent_publish(w, marker, sizeof(marker) - 1);
|
||
agent_publish(w, msg, strlen(msg));
|
||
agent_publish(w, "\x1b[0m\n", strlen("\x1b[0m\n"));
|
||
} else {
|
||
agent_publish(w, "✦ ", strlen("✦ "));
|
||
agent_publish(w, msg, strlen(msg));
|
||
agent_publish(w, "\n", 1);
|
||
}
|
||
}
|
||
|
||
static void agent_publishf_system_status(agent_worker *w, const char *fmt, ...) {
|
||
char stack[1024];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
int n = vsnprintf(stack, sizeof(stack), fmt, ap);
|
||
va_end(ap);
|
||
if (n <= 0) return;
|
||
if ((size_t)n < sizeof(stack)) {
|
||
agent_publish_system_status(w, stack);
|
||
return;
|
||
}
|
||
|
||
char *heap = xmalloc((size_t)n + 1);
|
||
va_start(ap, fmt);
|
||
vsnprintf(heap, (size_t)n + 1, fmt, ap);
|
||
va_end(ap);
|
||
agent_publish_system_status(w, heap);
|
||
free(heap);
|
||
}
|
||
|
||
static int agent_web_confirm(void *privdata, const char *message,
|
||
char *err, size_t err_len) {
|
||
agent_worker *w = privdata;
|
||
if (!w || w->cfg->non_interactive) {
|
||
snprintf(err, err_len,
|
||
"visible Chrome browser startup requires interactive approval");
|
||
return 0;
|
||
}
|
||
|
||
pthread_mutex_lock(&w->mu);
|
||
w->web_approval_pending = true;
|
||
w->web_approval_answered = false;
|
||
w->web_approval_result = false;
|
||
w->web_approval_error[0] = '\0';
|
||
snprintf(w->web_approval_message, sizeof(w->web_approval_message),
|
||
"%s", message ? message : "Start visible Chrome browser? (y/n) ");
|
||
agent_wake_locked(w);
|
||
while (!w->stop && !w->interrupt && !w->web_approval_answered)
|
||
pthread_cond_wait(&w->cond, &w->mu);
|
||
bool ok = w->web_approval_result;
|
||
if (!w->web_approval_answered && (w->stop || w->interrupt)) {
|
||
ok = false;
|
||
w->web_approval_pending = false;
|
||
snprintf(w->web_approval_error, sizeof(w->web_approval_error),
|
||
"interrupted");
|
||
}
|
||
if (!ok) {
|
||
snprintf(err, err_len, "%s",
|
||
w->web_approval_error[0] ? w->web_approval_error :
|
||
"user denied Chrome browser start");
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
return ok ? 1 : 0;
|
||
}
|
||
|
||
static void agent_web_log(void *privdata, const char *message) {
|
||
agent_worker *w = privdata;
|
||
if (!w || !message || !message[0]) return;
|
||
agent_trace(w, "web: %s", message);
|
||
}
|
||
|
||
static bool agent_web_cancel(void *privdata) {
|
||
return worker_should_interrupt(privdata);
|
||
}
|
||
|
||
static bool worker_take_web_approval_request(agent_worker *w,
|
||
char *message, size_t message_len) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool pending = w->web_approval_pending;
|
||
if (pending) {
|
||
snprintf(message, message_len, "%s", w->web_approval_message);
|
||
w->web_approval_pending = false;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
return pending;
|
||
}
|
||
|
||
static void worker_answer_web_approval(agent_worker *w, bool allow,
|
||
const char *deny_error) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->web_approval_result = allow;
|
||
w->web_approval_answered = true;
|
||
if (!allow)
|
||
snprintf(w->web_approval_error, sizeof(w->web_approval_error),
|
||
"%s", deny_error && deny_error[0] ? deny_error :
|
||
"user denied Chrome browser start");
|
||
pthread_cond_signal(&w->cond);
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* When a model turn finishes with a tool call, queued user messages should not
|
||
* preempt that tool. The worker asks the UI thread for the queue contents only
|
||
* after the tool result is appended, so the next model input can contain both
|
||
* the tool observation and the user's pending correction. */
|
||
static char *worker_request_queued_user_drain(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->queued_user_drain_pending = true;
|
||
w->queued_user_drain_answered = false;
|
||
free(w->queued_user_drain_text);
|
||
w->queued_user_drain_text = NULL;
|
||
agent_wake_locked(w);
|
||
pthread_cond_signal(&w->cond);
|
||
while (!w->stop && !w->queued_user_drain_answered)
|
||
pthread_cond_wait(&w->cond, &w->mu);
|
||
char *text = w->queued_user_drain_text;
|
||
w->queued_user_drain_text = NULL;
|
||
w->queued_user_drain_pending = false;
|
||
w->queued_user_drain_answered = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return text;
|
||
}
|
||
|
||
static bool worker_take_queued_user_drain_request(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool pending = w->queued_user_drain_pending;
|
||
if (pending) w->queued_user_drain_pending = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return pending;
|
||
}
|
||
|
||
static void worker_answer_queued_user_drain(agent_worker *w, char *text) {
|
||
pthread_mutex_lock(&w->mu);
|
||
free(w->queued_user_drain_text);
|
||
w->queued_user_drain_text = text;
|
||
w->queued_user_drain_answered = true;
|
||
pthread_cond_signal(&w->cond);
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* Synchronize the live DS4 session to a transcript. This is the agent's main
|
||
* cache-saving operation: if the requested transcript extends the live session,
|
||
* only the suffix is prefetched; otherwise the DS4 session rebuilds from the
|
||
* longest common prefix it can retain. */
|
||
static int agent_worker_sync_tokens(agent_worker *w, const ds4_tokens *tokens,
|
||
bool publish_progress,
|
||
char *err, size_t err_len) {
|
||
int old_pos = ds4_session_pos(w->session);
|
||
int common = ds4_session_common_prefix(w->session, tokens);
|
||
int cached = common == old_pos && tokens->len >= old_pos ? common : 0;
|
||
int suffix = tokens->len - cached;
|
||
if (suffix < 0) suffix = tokens->len;
|
||
|
||
if (publish_progress) {
|
||
pthread_mutex_lock(&w->mu);
|
||
unsigned prefill_label = w->status.state == AGENT_WORKER_PREFILL ?
|
||
w->status.prefill_label : agent_next_prefill_label();
|
||
w->status.state = AGENT_WORKER_PREFILL;
|
||
w->progress_base = cached;
|
||
w->progress_started_at = now_sec();
|
||
w->status.prefill_done = 0;
|
||
w->status.prefill_total = suffix;
|
||
w->status.prefill_label = prefill_label;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.generated = 0;
|
||
w->status.gen_tps = 0.0;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
ds4_session_set_progress(w->session, publish_progress ? worker_progress_cb : NULL,
|
||
publish_progress ? w : NULL);
|
||
ds4_session_set_display_progress(w->session,
|
||
publish_progress ? worker_progress_cb : NULL,
|
||
publish_progress ? w : NULL);
|
||
ds4_session_set_cancel(w->session, worker_cancel_session_cb, w);
|
||
int rc = ds4_session_sync(w->session, tokens, err, err_len);
|
||
ds4_session_set_cancel(w->session, NULL, NULL);
|
||
ds4_session_set_progress(w->session, NULL, NULL);
|
||
ds4_session_set_display_progress(w->session, NULL, NULL);
|
||
return rc;
|
||
}
|
||
|
||
/* Start a new session at the system/tool prompt. A fixed sysprompt.kv
|
||
* checkpoint avoids paying this prefill cost repeatedly, but only when the
|
||
* rendered prompt text still matches the file. The same fixed path is shared
|
||
* by Flash and Pro; agent_kv_load_path() checks the model id, so switching
|
||
* model families rebuilds this cache instead of restoring incompatible KV. */
|
||
static bool agent_worker_reset_to_sysprompt(agent_worker *w, char *err, size_t err_len) {
|
||
ds4_tokens sys = {0};
|
||
agent_worker_build_system_tokens(w, &sys);
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_kvstore_render_tokens_text(w->engine, &sys, &text_len);
|
||
if (!text) {
|
||
snprintf(err, err_len, "failed to render system prompt");
|
||
ds4_tokens_free(&sys);
|
||
return false;
|
||
}
|
||
|
||
bool loaded = false;
|
||
char load_err[160] = {0};
|
||
if (w->sysprompt_path) {
|
||
loaded = agent_kv_load_path(w, w->sysprompt_path, NULL,
|
||
text, text_len, &w->transcript,
|
||
NULL,
|
||
load_err, sizeof(load_err));
|
||
if (loaded) {
|
||
agent_trace(w, "sysprompt kv hit file=%s tokens=%d",
|
||
w->sysprompt_path, w->transcript.len);
|
||
}
|
||
}
|
||
|
||
if (!loaded) {
|
||
if (w->sysprompt_path)
|
||
agent_publish_system_status(w, "Updating system prompt cache...");
|
||
ds4_tokens_free(&w->transcript);
|
||
ds4_tokens_copy(&w->transcript, &sys);
|
||
if (agent_worker_sync_tokens(w, &w->transcript, true, err, err_len) != 0) {
|
||
free(text);
|
||
ds4_tokens_free(&sys);
|
||
return false;
|
||
}
|
||
if (w->sysprompt_path) {
|
||
char save_err[160] = {0};
|
||
char ignored_sha[41];
|
||
if (!agent_kv_save_path(w, w->sysprompt_path, &w->transcript,
|
||
"agent-system", ignored_sha,
|
||
NULL, 0,
|
||
save_err, sizeof(save_err)))
|
||
{
|
||
if (w->cfg->non_interactive) {
|
||
fprintf(stderr, "ds4-agent: failed to save system prompt KV: %s\n",
|
||
save_err);
|
||
} else {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "\nds4-agent: failed to save system prompt KV: ");
|
||
agent_buf_puts(&b, save_err);
|
||
agent_buf_puts(&b, "\n");
|
||
char *msg = agent_buf_take(&b);
|
||
agent_publish(w, msg, strlen(msg));
|
||
free(msg);
|
||
}
|
||
} else {
|
||
agent_trace(w, "sysprompt kv stored file=%s tokens=%d",
|
||
w->sysprompt_path, w->transcript.len);
|
||
}
|
||
}
|
||
}
|
||
|
||
agent_worker_note_system_prompt_seen(w);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->user_activity = false;
|
||
w->session_dirty = false;
|
||
w->status.state = AGENT_WORKER_IDLE;
|
||
w->status.prefill_done = 0;
|
||
w->status.prefill_total = 0;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.generated = 0;
|
||
w->status.gen_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
w->status.error[0] = '\0';
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
w->datetime_context_injected = false;
|
||
agent_worker_clear_session_identity(w);
|
||
free(text);
|
||
ds4_tokens_free(&sys);
|
||
return true;
|
||
}
|
||
|
||
static bool agent_worker_should_stop(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool stop = w->stop;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return stop;
|
||
}
|
||
|
||
static bool agent_worker_wait_distributed_route(agent_worker *w, char *err, size_t err_len) {
|
||
if (!w || !w->cfg ||
|
||
w->cfg->engine.distributed.role != DS4_DISTRIBUTED_COORDINATOR)
|
||
return true;
|
||
|
||
char last[160] = {0};
|
||
unsigned ticks = 0;
|
||
const struct timespec delay = {0, 250000000L};
|
||
for (;;) {
|
||
int ready = ds4_session_distributed_route_ready(w->session, err, err_len);
|
||
if (ready > 0) {
|
||
if (ticks != 0) {
|
||
if (w->cfg->non_interactive)
|
||
fprintf(stderr, "ds4-agent: distributed route ready\n");
|
||
else
|
||
agent_publish_system_status(w, "Distributed route ready.");
|
||
}
|
||
if (err_len) err[0] = '\0';
|
||
return true;
|
||
}
|
||
if (ready < 0) return false;
|
||
|
||
const char *why = err && err[0] ? err : "route incomplete";
|
||
if (strcmp(last, why) != 0 || (ticks % 20u) == 0) {
|
||
if (w->cfg->non_interactive) {
|
||
fprintf(stderr, "ds4-agent: waiting for distributed route: %s\n", why);
|
||
} else {
|
||
char msg[224];
|
||
snprintf(msg, sizeof(msg), "Waiting for distributed route: %s", why);
|
||
agent_publish_system_status(w, msg);
|
||
}
|
||
snprintf(last, sizeof(last), "%s", why);
|
||
}
|
||
if (agent_worker_should_stop(w)) {
|
||
snprintf(err, err_len, "agent stopped while waiting for distributed route");
|
||
return false;
|
||
}
|
||
nanosleep(&delay, NULL);
|
||
ticks++;
|
||
}
|
||
}
|
||
|
||
static bool agent_worker_has_user_session(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool yes = w->user_activity;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return yes;
|
||
}
|
||
|
||
static bool agent_worker_needs_save(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool yes = w->user_activity && w->session_dirty;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return yes;
|
||
}
|
||
|
||
/* Save the current session under its stable agent identity. The worker owns
|
||
* the live KV, so busy /save requests are deferred until a stable append-only
|
||
* point and then executed by the worker thread. */
|
||
static bool agent_worker_save_session_now(agent_worker *w, char sha_out[41],
|
||
int *tokens_out,
|
||
char *err, size_t err_len) {
|
||
if (!agent_worker_has_user_session(w)) {
|
||
snprintf(err, err_len, "nothing to save");
|
||
return false;
|
||
}
|
||
|
||
if (agent_worker_sync_tokens(w, &w->transcript, false, err, err_len) != 0)
|
||
return false;
|
||
if (!agent_mkdir_p(w->cache_dir)) {
|
||
snprintf(err, err_len, "failed to create %s", w->cache_dir);
|
||
return false;
|
||
}
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_kvstore_render_tokens_text(w->engine, &w->transcript,
|
||
&text_len);
|
||
if (!text) {
|
||
snprintf(err, err_len, "failed to render session text");
|
||
return false;
|
||
}
|
||
if (!w->session_title) {
|
||
w->session_title = agent_session_title_from_text(text, text_len, 0);
|
||
}
|
||
if (w->session_created_at == 0)
|
||
w->session_created_at = (uint64_t)time(NULL);
|
||
|
||
char sha[41];
|
||
agent_session_identity_sha(w->session_title, w->session_created_at, sha);
|
||
char *path = agent_kv_path_for_sha(w->cache_dir, sha);
|
||
|
||
bool ok = agent_kv_save_path(w, path, &w->transcript,
|
||
"agent-session", sha_out,
|
||
w->session_title, w->session_created_at,
|
||
err, err_len);
|
||
if (ok) {
|
||
memcpy(w->session_sha, sha, sizeof(w->session_sha));
|
||
if (w->legacy_session_path_to_delete &&
|
||
strcmp(w->legacy_session_path_to_delete, path) != 0)
|
||
{
|
||
unlink(w->legacy_session_path_to_delete);
|
||
}
|
||
free(w->legacy_session_path_to_delete);
|
||
w->legacy_session_path_to_delete = NULL;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->session_dirty = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
if (tokens_out) *tokens_out = w->transcript.len;
|
||
}
|
||
free(path);
|
||
free(text);
|
||
return ok;
|
||
}
|
||
|
||
static bool agent_worker_save_session(agent_worker *w, char *err, size_t err_len) {
|
||
if (!worker_is_idle(w)) {
|
||
snprintf(err, err_len, "model is busy");
|
||
return false;
|
||
}
|
||
char sha[41];
|
||
int tokens = 0;
|
||
bool ok = agent_worker_save_session_now(w, sha, &tokens, err, err_len);
|
||
if (ok) printf("saved session %.8s (%d tokens)\n", sha, tokens);
|
||
return ok;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Session Listing, History Rendering, And Completion
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void agent_format_age(uint64_t when, char *buf, size_t len) {
|
||
uint64_t now = (uint64_t)time(NULL);
|
||
uint64_t age = when && now > when ? now - when : 0;
|
||
if (age < 60) snprintf(buf, len, "%llus ago", (unsigned long long)age);
|
||
else if (age < 3600) snprintf(buf, len, "%llum ago", (unsigned long long)(age / 60));
|
||
else if (age < 86400) snprintf(buf, len, "%lluh ago", (unsigned long long)(age / 3600));
|
||
else snprintf(buf, len, "%llud ago", (unsigned long long)(age / 86400));
|
||
}
|
||
|
||
static char *agent_session_title_from_span(const char *p, const char *end,
|
||
size_t max_bytes,
|
||
const char *empty_title) {
|
||
bool limited = max_bytes != 0;
|
||
if (limited && max_bytes < 4) max_bytes = 4;
|
||
while (p < end && isspace((unsigned char)*p)) p++;
|
||
while (end > p && isspace((unsigned char)end[-1])) end--;
|
||
|
||
agent_buf b = {0};
|
||
bool space = false;
|
||
bool truncated = false;
|
||
for (const char *s = p; s < end; s++) {
|
||
unsigned char c = (unsigned char)*s;
|
||
if (isspace(c)) {
|
||
space = b.len != 0;
|
||
continue;
|
||
}
|
||
if (space && (!limited || b.len + 4 < max_bytes)) {
|
||
agent_buf_puts(&b, " ");
|
||
space = false;
|
||
}
|
||
if (limited && b.len + 4 > max_bytes) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
agent_buf_append(&b, s, 1);
|
||
}
|
||
if (truncated) agent_buf_puts(&b, "...");
|
||
if (!b.ptr || !b.len) {
|
||
free(b.ptr);
|
||
return xstrdup(empty_title);
|
||
}
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static char *agent_session_title_from_prompt(const char *prompt,
|
||
size_t max_bytes) {
|
||
const char *p = prompt ? prompt : "";
|
||
return agent_session_title_from_span(p, p + strlen(p), max_bytes,
|
||
"(empty user prompt)");
|
||
}
|
||
|
||
/* Extract a human-readable title from the first user turn stored in the
|
||
* rendered transcript. max_bytes==0 means "full normalized title"; callers
|
||
* that render to the terminal pass an explicit display budget. */
|
||
static char *agent_session_title_from_text(const char *text, size_t text_len,
|
||
size_t max_bytes) {
|
||
static const char user_mark[] = "<|User|>";
|
||
static const char assistant_mark[] = "<|Assistant|>";
|
||
const char *p = text ? strstr(text, user_mark) : NULL;
|
||
if (!p) return xstrdup("(no user prompt)");
|
||
p += strlen(user_mark);
|
||
const char *end = text + text_len;
|
||
const char *assistant = strstr(p, assistant_mark);
|
||
const char *next_user = strstr(p, user_mark);
|
||
if (assistant && assistant < end) end = assistant;
|
||
if (next_user && next_user < end) end = next_user;
|
||
return agent_session_title_from_span(p, end, max_bytes,
|
||
"(empty user prompt)");
|
||
}
|
||
|
||
static char *agent_session_title_clip(const char *title, size_t max_bytes) {
|
||
if (!title) return xstrdup("(no user prompt)");
|
||
size_t len = strlen(title);
|
||
if (max_bytes == 0 || len <= max_bytes) return xstrdup(title);
|
||
if (max_bytes < 4) max_bytes = 4;
|
||
agent_buf b = {0};
|
||
agent_buf_append(&b, title, max_bytes - 3);
|
||
agent_buf_puts(&b, "...");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static char *agent_session_title_from_file(const char *path, size_t max_bytes) {
|
||
FILE *fp = fopen(path, "rb");
|
||
if (!fp) return xstrdup("(unreadable session)");
|
||
ds4_kvstore_entry hdr = {0};
|
||
uint32_t text_bytes = 0;
|
||
char *text = NULL;
|
||
char *trailer_title = NULL;
|
||
bool ok = ds4_kvstore_read_header(fp, &hdr, &text_bytes) &&
|
||
agent_kv_read_text(fp, text_bytes, &text, NULL, 0);
|
||
if (ok && (hdr.ext_flags & DS4_KVSTORE_EXT_SESSION_TITLE))
|
||
ok = agent_kv_read_title_trailer(fp, &hdr, &trailer_title, NULL, 0);
|
||
fclose(fp);
|
||
char *title = ok ?
|
||
(trailer_title ?
|
||
agent_session_title_clip(trailer_title, max_bytes) :
|
||
agent_session_title_from_text(text, text_bytes, max_bytes)) :
|
||
xstrdup("(unreadable session)");
|
||
free(trailer_title);
|
||
free(text);
|
||
return title;
|
||
}
|
||
|
||
#define AGENT_HISTORY_DEFAULT_TURNS 3
|
||
#define AGENT_HISTORY_MAX_TURNS 200
|
||
#define AGENT_HISTORY_ASSISTANT_MAX_LINES 80
|
||
#define AGENT_HISTORY_ASSISTANT_MAX_BYTES 12000
|
||
|
||
typedef enum {
|
||
AGENT_HISTORY_MARK_NONE,
|
||
AGENT_HISTORY_MARK_USER,
|
||
AGENT_HISTORY_MARK_ASSISTANT,
|
||
AGENT_HISTORY_MARK_EOS,
|
||
} agent_history_mark;
|
||
|
||
typedef struct {
|
||
const char **v;
|
||
agent_history_mark *mark;
|
||
int len;
|
||
int cap;
|
||
} agent_history_ptrs;
|
||
|
||
static void agent_history_ptrs_push(agent_history_ptrs *p, const char *s,
|
||
agent_history_mark mark) {
|
||
if (p->len == p->cap) {
|
||
p->cap = p->cap ? p->cap * 2 : 16;
|
||
p->v = xrealloc(p->v, (size_t)p->cap * sizeof(p->v[0]));
|
||
p->mark = xrealloc(p->mark, (size_t)p->cap * sizeof(p->mark[0]));
|
||
}
|
||
p->v[p->len] = s;
|
||
p->mark[p->len] = mark;
|
||
p->len++;
|
||
}
|
||
|
||
static const char *agent_memmem(const char *hay, size_t hay_len,
|
||
const char *needle, size_t needle_len) {
|
||
if (!needle_len) return hay;
|
||
if (needle_len > hay_len) return NULL;
|
||
const char first = needle[0];
|
||
const char *end = hay + hay_len - needle_len + 1;
|
||
for (const char *p = hay; p < end; p++) {
|
||
if (*p == first && memcmp(p, needle, needle_len) == 0) return p;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static const char *agent_history_next_marker(const char *p, const char *end,
|
||
agent_history_mark *mark,
|
||
size_t *mark_len) {
|
||
static const char user_mark[] = "<|User|>";
|
||
static const char assistant_mark[] = "<|Assistant|>";
|
||
static const char eos_mark[] = "<|end▁of▁sentence|>";
|
||
const char *u = agent_memmem(p, (size_t)(end - p),
|
||
user_mark, sizeof(user_mark) - 1);
|
||
const char *a = agent_memmem(p, (size_t)(end - p),
|
||
assistant_mark, sizeof(assistant_mark) - 1);
|
||
const char *e = agent_memmem(p, (size_t)(end - p),
|
||
eos_mark, sizeof(eos_mark) - 1);
|
||
if (!u && !a && !e) return NULL;
|
||
if (u && (!a || u < a) && (!e || u < e)) {
|
||
if (mark) *mark = AGENT_HISTORY_MARK_USER;
|
||
if (mark_len) *mark_len = sizeof(user_mark) - 1;
|
||
return u;
|
||
}
|
||
if (a && (!e || a < e)) {
|
||
if (mark) *mark = AGENT_HISTORY_MARK_ASSISTANT;
|
||
if (mark_len) *mark_len = sizeof(assistant_mark) - 1;
|
||
return a;
|
||
}
|
||
if (mark) *mark = AGENT_HISTORY_MARK_EOS;
|
||
if (mark_len) *mark_len = sizeof(eos_mark) - 1;
|
||
return e;
|
||
}
|
||
|
||
static void agent_history_trim(const char **p, const char **end) {
|
||
while (*p < *end && isspace((unsigned char)**p)) (*p)++;
|
||
while (*end > *p && isspace((unsigned char)(*end)[-1])) (*end)--;
|
||
}
|
||
|
||
static bool agent_history_has_prefix(const char *p, const char *end,
|
||
const char *prefix) {
|
||
size_t n = strlen(prefix);
|
||
return (size_t)(end - p) >= n && memcmp(p, prefix, n) == 0;
|
||
}
|
||
|
||
/* Tool messages are rendered as user turns in the transcript. Return the
|
||
* inner payload for the current <tool_result> wrapper so /history skips these
|
||
* pseudo-user turns and displays their content without leaking the wrapper. */
|
||
static bool agent_history_tool_result_payload(const char **p, const char **end) {
|
||
const char *s = *p, *e = *end;
|
||
agent_history_trim(&s, &e);
|
||
|
||
const char *open = "<tool_result>";
|
||
const char *close = "</tool_result>";
|
||
const size_t open_len = strlen(open);
|
||
const size_t close_len = strlen(close);
|
||
if (!agent_history_has_prefix(s, e, open)) return false;
|
||
|
||
s += open_len;
|
||
if ((size_t)(e - s) >= close_len &&
|
||
memcmp(e - close_len, close, close_len) == 0)
|
||
{
|
||
e -= close_len;
|
||
}
|
||
*p = s;
|
||
*end = e;
|
||
return true;
|
||
}
|
||
|
||
static bool agent_history_is_tool_user(const char *p, const char *end) {
|
||
agent_history_trim(&p, &end);
|
||
return agent_history_tool_result_payload(&p, &end) ||
|
||
agent_history_has_prefix(p, end, "Tool:") ||
|
||
agent_history_has_prefix(p, end, "Tool result");
|
||
}
|
||
|
||
static void agent_history_ptrs_free(agent_history_ptrs *p) {
|
||
free(p->v);
|
||
free(p->mark);
|
||
memset(p, 0, sizeof(*p));
|
||
}
|
||
|
||
/* Find the oldest rendered-chat marker needed to show the last N user turns.
|
||
* Tool-result pseudo-user turns are skipped while human turns exist, so
|
||
* /history stays centered on the human conversation. Compacted sessions can
|
||
* legitimately have a tail made only of tool result turns; in that case we
|
||
* fall back to recent tool/assistant events instead of showing an empty
|
||
* history. */
|
||
static const char *agent_history_start_for_turns(const char *text, size_t len,
|
||
int user_turns,
|
||
bool *tool_only) {
|
||
const char *end = text + len;
|
||
agent_history_ptrs marks = {0};
|
||
agent_history_ptrs users = {0};
|
||
agent_history_ptrs all_users = {0};
|
||
const char *p = text;
|
||
while (p < end) {
|
||
agent_history_mark mark = AGENT_HISTORY_MARK_NONE;
|
||
size_t mark_len = 0;
|
||
const char *m = agent_history_next_marker(p, end, &mark, &mark_len);
|
||
if (!m) break;
|
||
agent_history_ptrs_push(&marks, m, mark);
|
||
const char *content = m + mark_len;
|
||
agent_history_mark next_mark = AGENT_HISTORY_MARK_NONE;
|
||
size_t next_len = 0;
|
||
const char *next = agent_history_next_marker(content, end,
|
||
&next_mark, &next_len);
|
||
const char *content_end = next ? next : end;
|
||
if (mark == AGENT_HISTORY_MARK_USER) {
|
||
agent_history_ptrs_push(&all_users, m, mark);
|
||
if (!agent_history_is_tool_user(content, content_end))
|
||
agent_history_ptrs_push(&users, m, mark);
|
||
}
|
||
p = content_end;
|
||
}
|
||
|
||
const char *start = end;
|
||
if (tool_only) *tool_only = false;
|
||
if (users.len > 0) {
|
||
int idx = users.len - user_turns;
|
||
if (idx < 0) idx = 0;
|
||
start = users.v[idx];
|
||
} else if (all_users.len > 0) {
|
||
int idx = all_users.len - user_turns;
|
||
if (idx < 0) idx = 0;
|
||
start = all_users.v[idx];
|
||
if (tool_only) *tool_only = true;
|
||
|
||
/* Tool result messages are stored as user-role turns after the
|
||
* assistant DSML stanza that produced them. Include that preceding
|
||
* assistant marker when it is still in the retained tail, otherwise
|
||
* replay shows the result but hides the call that caused it. */
|
||
for (int i = marks.len - 1; i >= 0; i--) {
|
||
if (marks.v[i] >= start) continue;
|
||
if (marks.mark[i] == AGENT_HISTORY_MARK_USER) break;
|
||
if (marks.mark[i] == AGENT_HISTORY_MARK_ASSISTANT) {
|
||
start = marks.v[i];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
agent_history_ptrs_free(&marks);
|
||
agent_history_ptrs_free(&users);
|
||
agent_history_ptrs_free(&all_users);
|
||
return start;
|
||
}
|
||
|
||
static bool agent_history_latest_compaction_summary(const char *text,
|
||
size_t len,
|
||
const char **sum_start,
|
||
const char **sum_end) {
|
||
static const char start_mark[] =
|
||
"[ds4-agent compacted earlier conversation. Durable task-state summary follows.]";
|
||
static const char end_mark[] =
|
||
"[End compacted summary. Recent conversation continues verbatim below.]";
|
||
const char *end = text + len;
|
||
const char *scan = text;
|
||
const char *best_start = NULL;
|
||
const char *best_end = NULL;
|
||
while (scan < end) {
|
||
const char *s = agent_memmem(scan, (size_t)(end - scan),
|
||
start_mark, sizeof(start_mark) - 1);
|
||
if (!s) break;
|
||
const char *content = s + sizeof(start_mark) - 1;
|
||
const char *e = agent_memmem(content, (size_t)(end - content),
|
||
end_mark, sizeof(end_mark) - 1);
|
||
if (!e) break;
|
||
best_start = content;
|
||
best_end = e;
|
||
scan = e + sizeof(end_mark) - 1;
|
||
}
|
||
if (!best_start || !best_end) return false;
|
||
agent_history_trim(&best_start, &best_end);
|
||
if (best_start >= best_end) return false;
|
||
if (sum_start) *sum_start = best_start;
|
||
if (sum_end) *sum_end = best_end;
|
||
return true;
|
||
}
|
||
|
||
static void agent_history_publish_limited(agent_worker *w, const char *p,
|
||
const char *end, int max_lines,
|
||
size_t max_bytes);
|
||
|
||
static void agent_history_render_compaction_summary(agent_worker *w,
|
||
const char *text,
|
||
size_t len) {
|
||
const char *p = NULL, *end = NULL;
|
||
if (!agent_history_latest_compaction_summary(text, len, &p, &end)) return;
|
||
bool color = isatty(STDOUT_FILENO) != 0;
|
||
if (color) {
|
||
const char *s = "\n\x1b[1;95mCompacted Summary:\x1b[0m\n";
|
||
agent_publish(w, s, strlen(s));
|
||
} else {
|
||
agent_publish(w, "\nCompacted Summary:\n",
|
||
strlen("\nCompacted Summary:\n"));
|
||
}
|
||
agent_history_publish_limited(w, p, end, 80, 12000);
|
||
}
|
||
|
||
static const char *agent_history_skip_utf8_continuation(const char *p,
|
||
const char *end) {
|
||
while (p < end && (((unsigned char)*p) & 0xc0) == 0x80) p++;
|
||
return p;
|
||
}
|
||
|
||
static const char *agent_history_tail_start(const char *p, const char *end,
|
||
int max_lines, size_t max_bytes,
|
||
bool *truncated) {
|
||
*truncated = false;
|
||
if (p >= end) return p;
|
||
|
||
const char *start = p;
|
||
size_t len = (size_t)(end - p);
|
||
if (max_bytes && len > max_bytes) {
|
||
start = end - max_bytes;
|
||
*truncated = true;
|
||
}
|
||
|
||
if (max_lines > 0) {
|
||
const char *scan = end;
|
||
if (scan > p && scan[-1] == '\n') scan--;
|
||
const char *line_start = p;
|
||
int lines = 0;
|
||
while (scan > p) {
|
||
scan--;
|
||
if (*scan == '\n' && ++lines == max_lines) {
|
||
line_start = scan + 1;
|
||
break;
|
||
}
|
||
}
|
||
if (line_start > p) *truncated = true;
|
||
if (line_start > start) start = line_start;
|
||
}
|
||
|
||
return agent_history_skip_utf8_continuation(start, end);
|
||
}
|
||
|
||
static void agent_history_publish_limited(agent_worker *w, const char *p,
|
||
const char *end, int max_lines,
|
||
size_t max_bytes) {
|
||
bool truncated = false;
|
||
const char *start = agent_history_tail_start(p, end, max_lines, max_bytes,
|
||
&truncated);
|
||
if (truncated)
|
||
agent_publish(w, "\n... earlier history truncated; showing tail ...\n",
|
||
strlen("\n... earlier history truncated; showing tail ...\n"));
|
||
agent_publish(w, start, (size_t)(end - start));
|
||
if (end > start && end[-1] != '\n') agent_publish(w, "\n", 1);
|
||
}
|
||
|
||
static void agent_history_render_assistant(agent_worker *w,
|
||
const char *p, const char *end) {
|
||
agent_history_trim(&p, &end);
|
||
if (p >= end) return;
|
||
bool source_truncated = false;
|
||
(void)agent_history_tail_start(p, end,
|
||
AGENT_HISTORY_ASSISTANT_MAX_LINES,
|
||
AGENT_HISTORY_ASSISTANT_MAX_BYTES,
|
||
&source_truncated);
|
||
bool use_color = isatty(STDOUT_FILENO) != 0;
|
||
agent_tail_capture tail = {
|
||
.cap = source_truncated ? AGENT_HISTORY_ASSISTANT_MAX_BYTES : 0,
|
||
};
|
||
agent_token_renderer renderer = {
|
||
.engine = w->engine,
|
||
.worker = w,
|
||
.format_thinking = true,
|
||
/* History replay should look like the original live output: the user is
|
||
* switching back to a session, not reading a different transcript
|
||
* format. Tool calls are still dry-rendered below, so replay never
|
||
* executes tools or mutates transcript state. */
|
||
.format_markdown = true,
|
||
.use_color = use_color && !source_truncated,
|
||
.last_output_newline = true,
|
||
.capture = source_truncated ? &tail : NULL,
|
||
};
|
||
agent_dsml_parser dsml = {.state = AGENT_DSML_SEARCH};
|
||
agent_stream_renderer stream = {
|
||
.renderer = &renderer,
|
||
.parser = &dsml,
|
||
.replay = true,
|
||
};
|
||
|
||
/* Dry-run replay: the same streaming projection hides DSML and renders
|
||
* semantic tool lines, but no tool is executed and no transcript state is
|
||
* changed. The saved KV payload remains the only authority for resume. */
|
||
agent_stream_text(&stream, p, (size_t)(end - p), true);
|
||
renderer_finish(&renderer);
|
||
agent_dsml_parser_free(&dsml);
|
||
|
||
if (source_truncated) {
|
||
size_t tail_len = 0;
|
||
char *tail_text = agent_tail_capture_take(&tail, &tail_len);
|
||
bool rendered_truncated = tail.total > tail_len;
|
||
bool line_truncated = false;
|
||
const char *tail_start =
|
||
agent_history_tail_start(tail_text, tail_text + tail_len,
|
||
AGENT_HISTORY_ASSISTANT_MAX_LINES,
|
||
AGENT_HISTORY_ASSISTANT_MAX_BYTES,
|
||
&line_truncated);
|
||
if (use_color) agent_publish(w, "\x1b[90m", 5);
|
||
agent_publish(w,
|
||
"\n... earlier assistant history truncated; showing tail ...\n",
|
||
strlen("\n... earlier assistant history truncated; showing tail ...\n"));
|
||
(void)rendered_truncated;
|
||
agent_publish(w, tail_start, (size_t)(tail_text + tail_len - tail_start));
|
||
if (tail_len && tail_text[tail_len - 1] != '\n') agent_publish(w, "\n", 1);
|
||
if (use_color) agent_publish(w, "\x1b[0m", 4);
|
||
free(tail_text);
|
||
}
|
||
}
|
||
|
||
/* Re-render saved transcript text for /history and /switch. It intentionally
|
||
* uses the same assistant/token renderer as live output, so restored history
|
||
* looks like the original terminal stream instead of raw rendered-chat text. */
|
||
static void agent_history_render_text(agent_worker *w, const char *text,
|
||
size_t len, int user_turns) {
|
||
if (user_turns <= 0) return;
|
||
if (user_turns > AGENT_HISTORY_MAX_TURNS)
|
||
user_turns = AGENT_HISTORY_MAX_TURNS;
|
||
|
||
const char *end = text + len;
|
||
agent_history_render_compaction_summary(w, text, len);
|
||
|
||
bool tool_only = false;
|
||
const char *p = agent_history_start_for_turns(text, len, user_turns,
|
||
&tool_only);
|
||
if (p >= end) {
|
||
agent_publish(w, "\n(no user history)\n", strlen("\n(no user history)\n"));
|
||
return;
|
||
}
|
||
|
||
bool color = isatty(STDOUT_FILENO) != 0;
|
||
if (color) agent_publish(w, "\n\x1b[90m", strlen("\n\x1b[90m"));
|
||
else agent_publish(w, "\n", 1);
|
||
if (tool_only) {
|
||
agent_publishf(w, "--- session history: recent tool/assistant events ---\n");
|
||
} else {
|
||
agent_publishf(w, "--- session history: last %d user turn%s ---\n",
|
||
user_turns, user_turns == 1 ? "" : "s");
|
||
}
|
||
if (color) agent_publish(w, "\x1b[0m", 4);
|
||
|
||
while (p < end) {
|
||
agent_history_mark mark = AGENT_HISTORY_MARK_NONE;
|
||
size_t mark_len = 0;
|
||
const char *m = agent_history_next_marker(p, end, &mark, &mark_len);
|
||
if (!m) break;
|
||
const char *content = m + mark_len;
|
||
agent_history_mark next_mark = AGENT_HISTORY_MARK_NONE;
|
||
size_t next_len = 0;
|
||
const char *next = agent_history_next_marker(content, end,
|
||
&next_mark, &next_len);
|
||
const char *content_end = next ? next : end;
|
||
const char *tp = content, *te = content_end;
|
||
agent_history_trim(&tp, &te);
|
||
|
||
if (mark == AGENT_HISTORY_MARK_USER) {
|
||
if (agent_history_is_tool_user(tp, te)) {
|
||
const char *payload_start = tp;
|
||
const char *payload_end = te;
|
||
(void)agent_history_tool_result_payload(&payload_start,
|
||
&payload_end);
|
||
if (color) {
|
||
const char *s = "\x1b[90mTool result:\n";
|
||
agent_publish(w, s, strlen(s));
|
||
} else {
|
||
agent_publish(w, "Tool result:\n", strlen("Tool result:\n"));
|
||
}
|
||
agent_history_publish_limited(w, payload_start, payload_end,
|
||
12, 3000);
|
||
if (color) agent_publish(w, "\x1b[0m", 4);
|
||
} else {
|
||
if (color) {
|
||
const char *s = "\x1b[1;32mUser:\x1b[0m\n";
|
||
agent_publish(w, s, strlen(s));
|
||
} else {
|
||
agent_publish(w, "User:\n", strlen("User:\n"));
|
||
}
|
||
agent_history_publish_limited(w, tp, te, 24, 6000);
|
||
}
|
||
} else if (mark == AGENT_HISTORY_MARK_ASSISTANT) {
|
||
if (color) {
|
||
const char *s = "\x1b[1;37mAssistant:\x1b[0m\n";
|
||
agent_publish(w, s, strlen(s));
|
||
} else {
|
||
agent_publish(w, "Assistant:\n", strlen("Assistant:\n"));
|
||
}
|
||
agent_history_render_assistant(w, tp, te);
|
||
}
|
||
p = content_end;
|
||
}
|
||
|
||
if (color) {
|
||
const char *s = "\x1b[90m--- end history ---\x1b[0m\n";
|
||
agent_publish(w, s, strlen(s));
|
||
} else {
|
||
agent_publish(w, "--- end history ---\n", strlen("--- end history ---\n"));
|
||
}
|
||
}
|
||
|
||
/* Render recent saved transcript text without mutating the live session. */
|
||
static bool agent_worker_show_history(agent_worker *w, int user_turns,
|
||
char *err, size_t err_len) {
|
||
if (!worker_is_idle(w)) {
|
||
snprintf(err, err_len, "model is busy");
|
||
return false;
|
||
}
|
||
size_t text_len = 0;
|
||
char *text = ds4_kvstore_render_tokens_text(w->engine, &w->transcript,
|
||
&text_len);
|
||
if (!text) {
|
||
snprintf(err, err_len, "failed to render session text");
|
||
return false;
|
||
}
|
||
agent_history_render_text(w, text, text_len, user_turns);
|
||
free(text);
|
||
return true;
|
||
}
|
||
|
||
typedef struct {
|
||
ds4_kvstore_entry entry;
|
||
char *title;
|
||
} agent_session_list_item;
|
||
|
||
static int agent_session_list_cmp_recent(const void *a, const void *b) {
|
||
const agent_session_list_item *sa = a, *sb = b;
|
||
uint64_t ta = sa->entry.last_used ? sa->entry.last_used : sa->entry.created_at;
|
||
uint64_t tb = sb->entry.last_used ? sb->entry.last_used : sb->entry.created_at;
|
||
if (ta < tb) return 1;
|
||
if (ta > tb) return -1;
|
||
return strcmp(sa->entry.sha, sb->entry.sha);
|
||
}
|
||
|
||
static void agent_session_list_free(agent_session_list_item *v, int n) {
|
||
for (int i = 0; i < n; i++) {
|
||
ds4_kvstore_entry_free(&v[i].entry);
|
||
free(v[i].title);
|
||
}
|
||
free(v);
|
||
}
|
||
|
||
static void agent_session_list_push(agent_session_list_item **v, int *len,
|
||
int *cap, ds4_kvstore_entry entry,
|
||
char *title) {
|
||
if (*len == *cap) {
|
||
*cap = *cap ? *cap * 2 : 16;
|
||
*v = xrealloc(*v, (size_t)*cap * sizeof((*v)[0]));
|
||
}
|
||
(*v)[(*len)++] = (agent_session_list_item){
|
||
.entry = entry,
|
||
.title = title,
|
||
};
|
||
}
|
||
|
||
/* Print resumable sessions from ~/.ds4/kvcache. sysprompt.kv is intentionally
|
||
* ignored because it is an implementation cache, not a user session. */
|
||
static void agent_worker_list_sessions(agent_worker *w) {
|
||
DIR *d = opendir(w->cache_dir);
|
||
if (!d) {
|
||
printf("no sessions: %s\n", strerror(errno));
|
||
return;
|
||
}
|
||
|
||
int cols = renderer_terminal_cols();
|
||
size_t title_budget = cols > 16 ? (size_t)(cols - 12) : 20;
|
||
if (title_budget > 160) title_budget = 160;
|
||
|
||
agent_session_list_item *sessions = NULL;
|
||
int sessions_len = 0, sessions_cap = 0;
|
||
const uint8_t model_id = (uint8_t)ds4_engine_model_id(w->engine);
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
char sha[41];
|
||
if (!ds4_kvstore_sha_hex_name(de->d_name, sha)) continue;
|
||
char *path = ds4_kvstore_path_join(w->cache_dir, de->d_name);
|
||
ds4_kvstore_entry e = {0};
|
||
if (ds4_kvstore_read_entry_file(path, sha, &e)) {
|
||
if (e.model_id == model_id) {
|
||
char *title = agent_session_title_from_file(path, title_budget);
|
||
agent_session_list_push(&sessions, &sessions_len, &sessions_cap,
|
||
e, title);
|
||
} else {
|
||
ds4_kvstore_entry_free(&e);
|
||
}
|
||
}
|
||
free(path);
|
||
}
|
||
closedir(d);
|
||
if (!sessions_len) {
|
||
printf("no saved sessions\n");
|
||
return;
|
||
}
|
||
|
||
qsort(sessions, (size_t)sessions_len, sizeof(sessions[0]),
|
||
agent_session_list_cmp_recent);
|
||
|
||
bool color = isatty(STDOUT_FILENO) != 0;
|
||
const char *sha_on = color ? "\x1b[1;96m" : "";
|
||
const char *title_on = color ? "\x1b[1;97m" : "";
|
||
const char *help_on = color ? "\x1b[97m" : "";
|
||
const char *dim = color ? "\x1b[90m" : "";
|
||
const char *reset = color ? "\x1b[0m" : "";
|
||
|
||
for (int i = 0; i < sessions_len; i++) {
|
||
ds4_kvstore_entry *e = &sessions[i].entry;
|
||
char age[32];
|
||
agent_format_age(e->last_used ? e->last_used : e->created_at,
|
||
age, sizeof(age));
|
||
printf("%s%.8s%s %s>%s %s%s%s\n",
|
||
sha_on, e->sha, reset, dim, reset,
|
||
title_on, sessions[i].title, reset);
|
||
printf(" %s> %s, %u tokens, %.2f MB%s%s\n\n",
|
||
dim, age, e->tokens,
|
||
(double)e->file_size / (1024.0 * 1024.0),
|
||
e->payload_bytes == 0 ? ", stripped" : "",
|
||
reset);
|
||
}
|
||
printf("%sUse /switch <id> to select a session, /del <id> to remove, "
|
||
"/strip <id> to strip KV cache.%s\n",
|
||
help_on, reset);
|
||
agent_session_list_free(sessions, sessions_len);
|
||
}
|
||
|
||
typedef struct {
|
||
char sha[41];
|
||
uint64_t last_used;
|
||
} agent_completion_session;
|
||
|
||
typedef struct {
|
||
agent_completion_session *v;
|
||
int len;
|
||
int cap;
|
||
} agent_completion_sessions;
|
||
|
||
static void agent_completion_sessions_push(agent_completion_sessions *s,
|
||
const char sha[41],
|
||
uint64_t last_used) {
|
||
if (s->len == s->cap) {
|
||
s->cap = s->cap ? s->cap * 2 : 16;
|
||
s->v = xrealloc(s->v, (size_t)s->cap * sizeof(s->v[0]));
|
||
}
|
||
memcpy(s->v[s->len].sha, sha, 41);
|
||
s->v[s->len].last_used = last_used;
|
||
s->len++;
|
||
}
|
||
|
||
static int agent_completion_session_cmp(const void *a, const void *b) {
|
||
const agent_completion_session *sa = a, *sb = b;
|
||
if (sa->last_used < sb->last_used) return 1;
|
||
if (sa->last_used > sb->last_used) return -1;
|
||
return strcmp(sa->sha, sb->sha);
|
||
}
|
||
|
||
/* Tab completion for /switch. Suggestions are sorted by recent use and accept
|
||
* either an empty prefix or any unambiguous hex prefix. */
|
||
static void agent_switch_completion_callback(const char *buf,
|
||
linenoiseCompletions *lc) {
|
||
agent_worker *w = agent_completion_worker;
|
||
static const char cmd[] = "/switch";
|
||
const size_t cmd_len = sizeof(cmd) - 1;
|
||
if (!w || !buf || strncmp(buf, cmd, cmd_len) != 0) return;
|
||
|
||
const char *p = buf + cmd_len;
|
||
if (*p && *p != ' ' && *p != '\t') return;
|
||
while (*p == ' ' || *p == '\t') p++;
|
||
|
||
const char *prefix = p;
|
||
size_t prefix_len = strlen(prefix);
|
||
for (size_t i = 0; i < prefix_len; i++) {
|
||
if (!isxdigit((unsigned char)prefix[i])) return;
|
||
}
|
||
if (prefix_len > 40) return;
|
||
|
||
DIR *d = opendir(w->cache_dir);
|
||
if (!d) return;
|
||
|
||
agent_completion_sessions sessions = {0};
|
||
const uint8_t model_id = (uint8_t)ds4_engine_model_id(w->engine);
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
char sha[41];
|
||
if (!ds4_kvstore_sha_hex_name(de->d_name, sha)) continue;
|
||
if (prefix_len && strncasecmp(sha, prefix, prefix_len) != 0) continue;
|
||
|
||
uint64_t last_used = 0;
|
||
char *path = ds4_kvstore_path_join(w->cache_dir, de->d_name);
|
||
ds4_kvstore_entry e = {0};
|
||
if (ds4_kvstore_read_entry_file(path, sha, &e)) {
|
||
if (e.model_id == model_id) last_used = e.last_used;
|
||
else last_used = UINT64_MAX;
|
||
ds4_kvstore_entry_free(&e);
|
||
} else {
|
||
last_used = UINT64_MAX;
|
||
}
|
||
free(path);
|
||
if (last_used == UINT64_MAX) continue;
|
||
agent_completion_sessions_push(&sessions, sha, last_used);
|
||
}
|
||
closedir(d);
|
||
|
||
qsort(sessions.v, (size_t)sessions.len, sizeof(sessions.v[0]),
|
||
agent_completion_session_cmp);
|
||
for (int i = 0; i < sessions.len; i++) {
|
||
char line[64];
|
||
int sha_chars = prefix_len > 8 ? 40 : 8;
|
||
snprintf(line, sizeof(line), "/switch %.*s",
|
||
sha_chars, sessions.v[i].sha);
|
||
linenoiseAddCompletion(lc, line);
|
||
}
|
||
free(sessions.v);
|
||
}
|
||
|
||
/* Resolve a user-provided SHA prefix to exactly one saved session file. */
|
||
static bool agent_worker_find_session(agent_worker *w, const char *prefix,
|
||
char sha_out[41], char **path_out,
|
||
char *err, size_t err_len) {
|
||
size_t plen = strlen(prefix);
|
||
if (plen == 0 || plen > 40) {
|
||
snprintf(err, err_len, "invalid session SHA prefix");
|
||
return false;
|
||
}
|
||
for (size_t i = 0; i < plen; i++) {
|
||
if (!isxdigit((unsigned char)prefix[i])) {
|
||
snprintf(err, err_len, "invalid session SHA prefix");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
DIR *d = opendir(w->cache_dir);
|
||
if (!d) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
return false;
|
||
}
|
||
int matches = 0;
|
||
char match_sha[41] = {0};
|
||
char *match_path = NULL;
|
||
const uint8_t model_id = (uint8_t)ds4_engine_model_id(w->engine);
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
char sha[41];
|
||
if (!ds4_kvstore_sha_hex_name(de->d_name, sha)) continue;
|
||
if (strncasecmp(sha, prefix, plen) != 0) continue;
|
||
char *path = ds4_kvstore_path_join(w->cache_dir, de->d_name);
|
||
ds4_kvstore_entry e = {0};
|
||
bool same_model = ds4_kvstore_read_entry_file(path, sha, &e) &&
|
||
e.model_id == model_id;
|
||
ds4_kvstore_entry_free(&e);
|
||
if (!same_model) {
|
||
free(path);
|
||
continue;
|
||
}
|
||
matches++;
|
||
if (matches == 1) {
|
||
memcpy(match_sha, sha, sizeof(match_sha));
|
||
match_path = path;
|
||
} else {
|
||
free(path);
|
||
}
|
||
}
|
||
closedir(d);
|
||
if (matches == 0) {
|
||
snprintf(err, err_len, "no saved session matches %.40s", prefix);
|
||
return false;
|
||
}
|
||
if (matches > 1) {
|
||
snprintf(err, err_len, "session prefix %.40s is ambiguous", prefix);
|
||
free(match_path);
|
||
return false;
|
||
}
|
||
memcpy(sha_out, match_sha, 41);
|
||
*path_out = match_path;
|
||
return true;
|
||
}
|
||
|
||
static bool agent_worker_delete_session(agent_worker *w, const char *prefix,
|
||
char sha_out[41],
|
||
char *err, size_t err_len) {
|
||
char sha[41];
|
||
char *path = NULL;
|
||
if (!agent_worker_find_session(w, prefix, sha, &path, err, err_len))
|
||
return false;
|
||
if (unlink(path) != 0) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
free(path);
|
||
return false;
|
||
}
|
||
if (sha_out) memcpy(sha_out, sha, 41);
|
||
free(path);
|
||
return true;
|
||
}
|
||
|
||
/* Strip the heavy backend payload from a saved session while preserving its
|
||
* rendered transcript. Loading such a file later tokenizes the text and
|
||
* rebuilds the live KV with a full prefill. */
|
||
static bool agent_worker_strip_session(agent_worker *w, const char *prefix,
|
||
char sha_out[41],
|
||
uint32_t *tokens_out,
|
||
char *err, size_t err_len) {
|
||
if (err && err_len) err[0] = '\0';
|
||
char sha[41];
|
||
char *path = NULL;
|
||
if (!agent_worker_find_session(w, prefix, sha, &path, err, err_len))
|
||
return false;
|
||
|
||
FILE *fp = fopen(path, "rb");
|
||
if (!fp) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
free(path);
|
||
return false;
|
||
}
|
||
|
||
ds4_kvstore_entry hdr = {0};
|
||
uint32_t text_bytes = 0;
|
||
char *text = NULL;
|
||
char *title = NULL;
|
||
bool ok = ds4_kvstore_read_header(fp, &hdr, &text_bytes) &&
|
||
agent_kv_read_text(fp, text_bytes, &text, err, err_len);
|
||
if (ok && (hdr.ext_flags & DS4_KVSTORE_EXT_SESSION_TITLE))
|
||
ok = agent_kv_read_title_trailer(fp, &hdr, &title, err, err_len);
|
||
fclose(fp);
|
||
if (!ok) {
|
||
if (!err[0]) snprintf(err, err_len, "failed to read session");
|
||
free(title);
|
||
free(text);
|
||
free(path);
|
||
return false;
|
||
}
|
||
|
||
char actual_sha[41];
|
||
agent_kv_identity_sha(&hdr, text, text_bytes, title, actual_sha);
|
||
if (strcmp(actual_sha, sha)) {
|
||
snprintf(err, err_len, "cached session identity does not match file name");
|
||
free(title);
|
||
free(text);
|
||
free(path);
|
||
return false;
|
||
}
|
||
|
||
ds4_tokens stripped_tokens = {0};
|
||
ds4_tokenize_rendered_chat(w->engine, text, &stripped_tokens);
|
||
uint32_t stripped_token_count = (uint32_t)stripped_tokens.len;
|
||
ds4_tokens_free(&stripped_tokens);
|
||
|
||
agent_buf tmpl = {0};
|
||
agent_buf_puts(&tmpl, path);
|
||
agent_buf_puts(&tmpl, ".tmp.XXXXXX");
|
||
char *tmp = agent_buf_take(&tmpl);
|
||
int fd = mkstemp(tmp);
|
||
if (fd < 0) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
free(tmp);
|
||
free(text);
|
||
free(path);
|
||
return false;
|
||
}
|
||
|
||
fp = fdopen(fd, "wb");
|
||
if (!fp) {
|
||
snprintf(err, err_len, "%s", strerror(errno));
|
||
close(fd);
|
||
unlink(tmp);
|
||
free(tmp);
|
||
free(text);
|
||
free(path);
|
||
return false;
|
||
}
|
||
|
||
uint8_t h[DS4_KVSTORE_FIXED_HEADER];
|
||
uint64_t now = (uint64_t)time(NULL);
|
||
ds4_kvstore_fill_header(h, hdr.model_id, hdr.quant_bits, hdr.reason, hdr.ext_flags,
|
||
stripped_token_count, hdr.hits, hdr.ctx_size,
|
||
hdr.created_at, now, 0);
|
||
uint8_t tb[4];
|
||
ds4_kvstore_le_put32(tb, text_bytes);
|
||
|
||
errno = 0;
|
||
ok = fwrite(h, 1, sizeof(h), fp) == sizeof(h) &&
|
||
fwrite(tb, 1, sizeof(tb), fp) == sizeof(tb) &&
|
||
fwrite(text, 1, text_bytes, fp) == text_bytes &&
|
||
(!(hdr.ext_flags & DS4_KVSTORE_EXT_SESSION_TITLE) ||
|
||
agent_kv_write_title_trailer(fp, title, err, err_len)) &&
|
||
fflush(fp) == 0;
|
||
int saved_errno = errno;
|
||
if (fclose(fp) != 0) {
|
||
if (!saved_errno) saved_errno = errno;
|
||
ok = false;
|
||
}
|
||
if (ok && rename(tmp, path) != 0) {
|
||
saved_errno = errno;
|
||
ok = false;
|
||
}
|
||
if (!ok) {
|
||
snprintf(err, err_len, "%s",
|
||
saved_errno ? strerror(saved_errno) : "failed to write stripped session");
|
||
unlink(tmp);
|
||
} else {
|
||
if (sha_out) memcpy(sha_out, sha, 41);
|
||
if (tokens_out) *tokens_out = stripped_token_count;
|
||
}
|
||
|
||
free(tmp);
|
||
free(title);
|
||
free(text);
|
||
free(path);
|
||
return ok;
|
||
}
|
||
|
||
/* Load a saved session KV into the live transcript and optionally replay recent
|
||
* history for the human. */
|
||
static bool agent_worker_switch_session(agent_worker *w, const char *prefix,
|
||
int history_turns,
|
||
char *err, size_t err_len) {
|
||
if (!worker_is_idle(w)) {
|
||
snprintf(err, err_len, "model is busy");
|
||
return false;
|
||
}
|
||
char sha[41];
|
||
char *path = NULL;
|
||
if (!agent_worker_find_session(w, prefix, sha, &path, err, err_len))
|
||
return false;
|
||
|
||
bool stripped = false;
|
||
ds4_kvstore_entry entry = {0};
|
||
if (ds4_kvstore_read_entry_file(path, sha, &entry)) {
|
||
stripped = entry.payload_bytes == 0;
|
||
ds4_kvstore_entry_free(&entry);
|
||
}
|
||
if (stripped) {
|
||
printf("rebuilding stripped session %.8s from rendered text...\n", sha);
|
||
fflush(stdout);
|
||
}
|
||
|
||
ds4_tokens loaded = {0};
|
||
agent_kv_session_meta meta = {0};
|
||
bool ok = agent_kv_load_path(w, path, sha, NULL, 0, &loaded, &meta,
|
||
err, err_len);
|
||
if (ok) {
|
||
ds4_tokens_free(&w->transcript);
|
||
w->transcript = loaded;
|
||
free(w->session_title);
|
||
w->session_title = meta.title ? xstrdup(meta.title) : xstrdup("(no user prompt)");
|
||
w->session_created_at = meta.created_at ? meta.created_at : (uint64_t)time(NULL);
|
||
memcpy(w->session_sha, sha, sizeof(w->session_sha));
|
||
free(w->legacy_session_path_to_delete);
|
||
w->legacy_session_path_to_delete = meta.legacy_identity ? xstrdup(path) : NULL;
|
||
agent_worker_note_system_prompt_seen(w);
|
||
w->datetime_context_injected = true;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->user_activity = true;
|
||
w->session_dirty = false;
|
||
w->status.state = AGENT_WORKER_IDLE;
|
||
w->status.ctx_used = w->transcript.len;
|
||
w->status.ctx_size = w->cfg->gen.ctx_size;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
w->status.error[0] = '\0';
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
printf("switched to session %.8s (%d tokens%s)\n",
|
||
sha, w->transcript.len, stripped ? ", rebuilt from text" : "");
|
||
if (history_turns > 0)
|
||
(void)agent_worker_show_history(w, history_turns, err, err_len);
|
||
} else {
|
||
ds4_tokens_free(&loaded);
|
||
}
|
||
agent_kv_session_meta_free(&meta);
|
||
free(path);
|
||
return ok;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Tool Argument Parsing And File Tool Helpers
|
||
* ============================================================================
|
||
*/
|
||
|
||
static int agent_parse_timeout(const char *s) {
|
||
if (!s || !s[0]) return 3600;
|
||
char *end = NULL;
|
||
double v = strtod(s, &end);
|
||
if (end == s || v <= 0.0 || !isfinite(v)) return 3600;
|
||
if (v < 1.0) v = 1.0;
|
||
if (v > 24.0 * 3600.0) v = 24.0 * 3600.0;
|
||
return (int)v;
|
||
}
|
||
|
||
static int agent_parse_int_default(const char *s, int def, int min, int max) {
|
||
if (!s || !s[0]) return def;
|
||
char *end = NULL;
|
||
long v = strtol(s, &end, 10);
|
||
if (end == s) return def;
|
||
while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') end++;
|
||
if (*end) return def;
|
||
if (v < min) v = min;
|
||
if (v > max) v = max;
|
||
return (int)v;
|
||
}
|
||
|
||
static bool agent_parse_bool_default(const char *s, bool def) {
|
||
if (!s || !s[0]) return def;
|
||
if (!strcasecmp(s, "true") || !strcasecmp(s, "yes") || !strcmp(s, "1"))
|
||
return true;
|
||
if (!strcasecmp(s, "false") || !strcasecmp(s, "no") || !strcmp(s, "0"))
|
||
return false;
|
||
return def;
|
||
}
|
||
|
||
#define AGENT_FILE_MAX_BYTES (16*1024*1024)
|
||
#define AGENT_READ_DEFAULT_LINES 500
|
||
#define AGENT_TOOL_RESULT_RESERVE_TOKENS 1024
|
||
#define AGENT_EDIT_UPTO_MIN_PREFIX_BYTES 64
|
||
#define AGENT_EDIT_UPTO_MIN_PREFIX_LINES 2
|
||
#define AGENT_COMPACT_SOFT_PERCENT 85
|
||
#define AGENT_COMPACT_MIN_FREE_TOKENS 8192
|
||
#define AGENT_COMPACT_TAIL_DIVISOR 10
|
||
#define AGENT_COMPACT_TAIL_CAP_TOKENS 50000
|
||
#define AGENT_COMPACT_SUMMARY_MAX_TOKENS 4096
|
||
|
||
typedef struct {
|
||
size_t start;
|
||
size_t content_end;
|
||
size_t end;
|
||
} agent_line_span;
|
||
|
||
typedef struct {
|
||
agent_line_span *v;
|
||
int len;
|
||
int cap;
|
||
} agent_line_spans;
|
||
|
||
static void agent_line_spans_free(agent_line_spans *spans) {
|
||
free(spans->v);
|
||
memset(spans, 0, sizeof(*spans));
|
||
}
|
||
|
||
static void agent_line_spans_push(agent_line_spans *spans, agent_line_span span) {
|
||
if (spans->len == spans->cap) {
|
||
spans->cap = spans->cap ? spans->cap * 2 : 128;
|
||
spans->v = xrealloc(spans->v, (size_t)spans->cap * sizeof(spans->v[0]));
|
||
}
|
||
spans->v[spans->len++] = span;
|
||
}
|
||
|
||
/* Split a text buffer into line spans. content_end excludes CR/LF so callers
|
||
* can print or compare line content without newline spelling differences. */
|
||
static void agent_split_lines(const char *data, size_t len, agent_line_spans *spans) {
|
||
size_t pos = 0;
|
||
while (pos < len) {
|
||
size_t start = pos;
|
||
while (pos < len && data[pos] != '\n' && data[pos] != '\r') pos++;
|
||
size_t content_end = pos;
|
||
if (pos < len) {
|
||
if (data[pos] == '\r' && pos + 1 < len && data[pos + 1] == '\n')
|
||
pos += 2;
|
||
else
|
||
pos++;
|
||
}
|
||
agent_line_spans_push(spans, (agent_line_span){
|
||
.start = start,
|
||
.content_end = content_end,
|
||
.end = pos,
|
||
});
|
||
}
|
||
}
|
||
|
||
static int agent_read_file_bytes(const char *path, char **data, size_t *len,
|
||
char *err, size_t errlen) {
|
||
FILE *fp = fopen(path, "rb");
|
||
if (!fp) {
|
||
snprintf(err, errlen, "open %s: %s", path, strerror(errno));
|
||
return -1;
|
||
}
|
||
char *buf = NULL;
|
||
size_t used = 0, cap = 0;
|
||
char tmp[8192];
|
||
while (true) {
|
||
size_t n = fread(tmp, 1, sizeof(tmp), fp);
|
||
if (n) {
|
||
if (used + n > AGENT_FILE_MAX_BYTES) {
|
||
fclose(fp);
|
||
free(buf);
|
||
snprintf(err, errlen, "file too large: %s exceeds %d bytes",
|
||
path, AGENT_FILE_MAX_BYTES);
|
||
return -1;
|
||
}
|
||
if (used + n + 1 > cap) {
|
||
cap = cap ? cap * 2 : 8192;
|
||
while (cap < used + n + 1) cap *= 2;
|
||
buf = xrealloc(buf, cap);
|
||
}
|
||
memcpy(buf + used, tmp, n);
|
||
used += n;
|
||
buf[used] = '\0';
|
||
}
|
||
if (n < sizeof(tmp)) {
|
||
if (ferror(fp)) {
|
||
snprintf(err, errlen, "read %s: %s", path, strerror(errno));
|
||
fclose(fp);
|
||
free(buf);
|
||
return -1;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
fclose(fp);
|
||
if (!buf) buf = xstrdup("");
|
||
*data = buf;
|
||
*len = used;
|
||
return 0;
|
||
}
|
||
|
||
static int agent_line_for_offset(const agent_line_spans *spans, size_t offset) {
|
||
if (!spans || spans->len <= 0) return 1;
|
||
for (int i = 0; i < spans->len; i++) {
|
||
if (offset < spans->v[i].end) return i + 1;
|
||
}
|
||
return spans->len;
|
||
}
|
||
|
||
static bool agent_old_new_line_effect(const char *old_data, size_t old_len,
|
||
const char *new_data, size_t new_len,
|
||
size_t edit_offset, size_t replaced_len,
|
||
int *start_line, int *end_line,
|
||
int *delta) {
|
||
agent_line_spans old_spans = {0};
|
||
agent_line_spans new_spans = {0};
|
||
agent_split_lines(old_data, old_len, &old_spans);
|
||
agent_split_lines(new_data, new_len, &new_spans);
|
||
bool ok = old_spans.len > 0;
|
||
if (ok) {
|
||
size_t old_last = edit_offset;
|
||
if (replaced_len > 0) old_last = edit_offset + replaced_len - 1;
|
||
if (old_last >= old_len) old_last = old_len ? old_len - 1 : 0;
|
||
if (start_line) *start_line = agent_line_for_offset(&old_spans, edit_offset);
|
||
if (end_line) *end_line = agent_line_for_offset(&old_spans, old_last);
|
||
if (delta) *delta = new_spans.len - old_spans.len;
|
||
}
|
||
agent_line_spans_free(&old_spans);
|
||
agent_line_spans_free(&new_spans);
|
||
return ok;
|
||
}
|
||
|
||
static void agent_edit_result_append_context(agent_buf *b,
|
||
const char *path,
|
||
const char *data, size_t len,
|
||
int anchor_start,
|
||
int anchor_end);
|
||
|
||
static char *agent_edit_result(const char *path,
|
||
int start_line, int end_line, int delta,
|
||
const char *new_data, size_t new_len,
|
||
const char *kind) {
|
||
agent_buf b = {0};
|
||
char msg[PATH_MAX + 180];
|
||
snprintf(msg, sizeof(msg), "Edited %s using %s\n", path, kind);
|
||
agent_buf_puts(&b, msg);
|
||
if (start_line > 0 && end_line >= start_line) {
|
||
snprintf(msg, sizeof(msg),
|
||
"Touched old lines %d-%d; current post-edit context follows.\n",
|
||
start_line, end_line);
|
||
agent_buf_puts(&b, msg);
|
||
if (delta != 0) {
|
||
snprintf(msg, sizeof(msg),
|
||
"Line shift: old lines after %d moved by %+d (old line %d is now line %d). Re-read before relying on old line numbers there.\n",
|
||
end_line, delta, end_line + 1, end_line + 1 + delta);
|
||
agent_buf_puts(&b, msg);
|
||
}
|
||
}
|
||
if (start_line > 0 && end_line >= start_line) {
|
||
int new_anchor_end = end_line + delta;
|
||
if (new_anchor_end < start_line) new_anchor_end = start_line;
|
||
agent_edit_result_append_context(&b, path, new_data, new_len,
|
||
start_line, new_anchor_end);
|
||
}
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static void agent_worker_set_more(agent_worker *w, const char *path,
|
||
int next_line, bool bare) {
|
||
snprintf(w->more_path, sizeof(w->more_path), "%s", path ? path : "");
|
||
w->more_next_line = next_line;
|
||
w->more_bare = bare;
|
||
w->more_valid = path && path[0] && next_line > 0;
|
||
}
|
||
|
||
static bool agent_tool_result_fits_context(agent_worker *w, const char *result,
|
||
int reserve_tokens,
|
||
int *tokens_out) {
|
||
ds4_tokens tmp = {0};
|
||
ds4_tokens_copy(&tmp, &w->transcript);
|
||
ds4_chat_append_message(w->engine, &tmp, "tool", result ? result : "");
|
||
int tokens = tmp.len;
|
||
ds4_tokens_free(&tmp);
|
||
if (tokens_out) *tokens_out = tokens;
|
||
return tokens + reserve_tokens < w->cfg->gen.ctx_size;
|
||
}
|
||
|
||
/* Read file text for the model. Normal mode shows plain line numbers. Raw
|
||
* mode is reserved for cases where line decoration would corrupt the payload
|
||
* being inspected. */
|
||
static char *agent_read_range(agent_worker *w, const char *path, int start_line,
|
||
int max_lines, bool whole_file, bool bare,
|
||
bool set_more) {
|
||
char err[256];
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (!path || !path[0]) return xstrdup("Tool error: read requires path\n");
|
||
if (agent_read_file_bytes(path, &data, &len, err, sizeof(err)) != 0) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: ");
|
||
agent_buf_puts(&b, err);
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
agent_line_spans spans = {0};
|
||
agent_split_lines(data, len, &spans);
|
||
if (start_line < 1) start_line = 1;
|
||
int start_idx = start_line - 1;
|
||
if (start_idx > spans.len) start_idx = spans.len;
|
||
if (whole_file) {
|
||
max_lines = spans.len - start_idx;
|
||
} else {
|
||
if (max_lines <= 0) max_lines = AGENT_READ_DEFAULT_LINES;
|
||
}
|
||
int end_idx = start_idx + max_lines;
|
||
if (end_idx > spans.len) end_idx = spans.len;
|
||
|
||
agent_buf out = {0};
|
||
if (bare) {
|
||
size_t start = start_idx < spans.len ? spans.v[start_idx].start : len;
|
||
size_t end = end_idx > start_idx ? spans.v[end_idx - 1].end : start;
|
||
agent_buf_append(&out, data + start, end - start);
|
||
if (end > start && out.ptr[out.len - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
if (end_idx < spans.len) {
|
||
char note[160];
|
||
snprintf(note, sizeof(note),
|
||
"[Read truncated at line %d of %d. continue_offset=%d. "
|
||
"Call more with count=%d to read the next chunk.]\n",
|
||
end_idx, spans.len, end_idx + 1,
|
||
max_lines > 0 ? max_lines : AGENT_READ_DEFAULT_LINES);
|
||
agent_buf_puts(&out, note);
|
||
}
|
||
} else {
|
||
char hdr[PATH_MAX + 160];
|
||
if (end_idx < spans.len) {
|
||
snprintf(hdr, sizeof(hdr),
|
||
"%s: lines %d-%d of %d; continue_offset=%d; "
|
||
"call more with count=%d to read the next chunk\n",
|
||
path, spans.len ? start_idx + 1 : 0, end_idx, spans.len,
|
||
end_idx + 1, max_lines > 0 ? max_lines : AGENT_READ_DEFAULT_LINES);
|
||
} else {
|
||
snprintf(hdr, sizeof(hdr), "%s: lines %d-%d of %d\n",
|
||
path, spans.len ? start_idx + 1 : 0, end_idx, spans.len);
|
||
}
|
||
agent_buf_puts(&out, hdr);
|
||
for (int i = start_idx; i < end_idx; i++) {
|
||
agent_line_span sp = spans.v[i];
|
||
char prefix[64];
|
||
snprintf(prefix, sizeof(prefix), "%d ", i + 1);
|
||
agent_buf_puts(&out, prefix);
|
||
agent_buf_append(&out, data + sp.start, sp.content_end - sp.start);
|
||
agent_buf_puts(&out, "\n");
|
||
}
|
||
}
|
||
if (set_more) {
|
||
if (end_idx < spans.len) agent_worker_set_more(w, path, end_idx + 1, bare);
|
||
else agent_worker_set_more(w, NULL, 0, false);
|
||
}
|
||
agent_line_spans_free(&spans);
|
||
free(data);
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
static char *agent_tool_read(agent_worker *w, const agent_tool_call *call) {
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
bool whole = agent_parse_bool_default(agent_tool_arg_value(call, "whole"), false);
|
||
int start = agent_parse_int_default(agent_tool_arg_value(call, "start_line"),
|
||
1, 1, INT_MAX);
|
||
int count = agent_parse_int_default(agent_tool_arg_value(call, "max_lines"),
|
||
AGENT_READ_DEFAULT_LINES, 1, INT_MAX);
|
||
bool raw = agent_parse_bool_default(agent_tool_arg_value(call, "raw"), false);
|
||
return agent_read_range(w, path, start, count, whole, raw, true);
|
||
}
|
||
|
||
static char *agent_tool_more(agent_worker *w, const agent_tool_call *call) {
|
||
int count = agent_parse_int_default(agent_tool_arg_value(call, "count"),
|
||
AGENT_READ_DEFAULT_LINES, 1, INT_MAX);
|
||
if (!w->more_valid) return xstrdup("Tool error: no previous output to continue\n");
|
||
return agent_read_range(w, w->more_path, w->more_next_line, count, false,
|
||
w->more_bare, true);
|
||
}
|
||
|
||
static char *agent_tool_write(agent_worker *w, const agent_tool_call *call) {
|
||
(void)w;
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
const char *content = agent_tool_arg_value(call, "content");
|
||
if (!path || !path[0]) return xstrdup("Tool error: write requires path\n");
|
||
if (!content) return xstrdup("Tool error: write requires content\n");
|
||
FILE *fp = fopen(path, "wb");
|
||
if (!fp) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: open for write failed: ");
|
||
agent_buf_puts(&b, strerror(errno));
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
size_t len = strlen(content);
|
||
size_t wr = fwrite(content, 1, len, fp);
|
||
int close_rc = fclose(fp);
|
||
if (wr != len || close_rc != 0) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: write failed: ");
|
||
agent_buf_puts(&b, strerror(errno));
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
char msg[PATH_MAX + 160];
|
||
snprintf(msg, sizeof(msg), "Wrote %zu bytes to %s\n", len, path);
|
||
return xstrdup(msg);
|
||
}
|
||
|
||
static char *agent_tool_list(const agent_tool_call *call) {
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
if (!path || !path[0]) path = ".";
|
||
DIR *dir = opendir(path);
|
||
if (!dir) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: opendir failed: ");
|
||
agent_buf_puts(&b, strerror(errno));
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
agent_buf out = {0};
|
||
char hdr[PATH_MAX + 64];
|
||
snprintf(hdr, sizeof(hdr), "%s:\n", path);
|
||
agent_buf_puts(&out, hdr);
|
||
struct dirent *de;
|
||
int shown = 0;
|
||
while ((de = readdir(dir)) != NULL && shown < 300) {
|
||
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
|
||
char full[PATH_MAX];
|
||
snprintf(full, sizeof(full), "%s/%s", path, de->d_name);
|
||
struct stat st;
|
||
if (lstat(full, &st) != 0) continue;
|
||
char type = S_ISDIR(st.st_mode) ? 'd' :
|
||
S_ISLNK(st.st_mode) ? 'l' :
|
||
S_ISREG(st.st_mode) ? '-' : '?';
|
||
char line[PATH_MAX + 96];
|
||
snprintf(line, sizeof(line), "%c %10lld %s%s\n", type,
|
||
(long long)st.st_size, de->d_name, S_ISDIR(st.st_mode) ? "/" : "");
|
||
agent_buf_puts(&out, line);
|
||
shown++;
|
||
}
|
||
if (de) agent_buf_puts(&out, "... more entries omitted ...\n");
|
||
closedir(dir);
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Edit And Search Tools
|
||
* ============================================================================
|
||
*/
|
||
|
||
static int agent_write_file_bytes(const char *path, const char *data, size_t len,
|
||
char *err, size_t errlen) {
|
||
FILE *fp = fopen(path, "wb");
|
||
if (!fp) {
|
||
snprintf(err, errlen, "open %s: %s", path, strerror(errno));
|
||
return -1;
|
||
}
|
||
size_t wr = fwrite(data, 1, len, fp);
|
||
if (wr != len) {
|
||
snprintf(err, errlen, "write %s: %s", path, strerror(errno));
|
||
fclose(fp);
|
||
return -1;
|
||
}
|
||
if (fclose(fp) != 0) {
|
||
snprintf(err, errlen, "close %s: %s", path, strerror(errno));
|
||
return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static void agent_edit_result_append_line(agent_buf *b, const char *data,
|
||
const agent_line_span *sp,
|
||
int line) {
|
||
char prefix[64];
|
||
snprintf(prefix, sizeof(prefix), "%d ", line);
|
||
agent_buf_puts(b, prefix);
|
||
agent_buf_append(b, data + sp->start, sp->content_end - sp->start);
|
||
agent_buf_puts(b, "\n");
|
||
}
|
||
|
||
/* Successful edits return the nearby post-edit file shape. This spends cheap
|
||
* prefill tokens to save expensive model retries: the model immediately sees
|
||
* shifted line numbers, braces, semicolons, and accidental duplication. */
|
||
static void agent_edit_result_append_context(agent_buf *b,
|
||
const char *path,
|
||
const char *data, size_t len,
|
||
int anchor_start,
|
||
int anchor_end) {
|
||
enum {
|
||
CONTEXT_BEFORE = 5,
|
||
CONTEXT_AFTER = 8,
|
||
EDITED_CONTEXT_HEAD = 18,
|
||
EDITED_CONTEXT_TAIL = 18
|
||
};
|
||
|
||
agent_line_spans spans = {0};
|
||
agent_split_lines(data, len, &spans);
|
||
if (spans.len <= 0) {
|
||
agent_line_spans_free(&spans);
|
||
return;
|
||
}
|
||
|
||
if (anchor_start < 1) anchor_start = 1;
|
||
if (anchor_start > spans.len) anchor_start = spans.len;
|
||
if (anchor_end < anchor_start) anchor_end = anchor_start;
|
||
if (anchor_end > spans.len) anchor_end = spans.len;
|
||
|
||
int ctx_start = anchor_start - CONTEXT_BEFORE;
|
||
if (ctx_start < 1) ctx_start = 1;
|
||
int ctx_end = anchor_end + CONTEXT_AFTER;
|
||
if (ctx_end > spans.len) ctx_end = spans.len;
|
||
|
||
char hdr[PATH_MAX + 160];
|
||
snprintf(hdr, sizeof(hdr),
|
||
"Current file around edit: %s lines %d-%d of %d\n",
|
||
path, ctx_start, ctx_end, spans.len);
|
||
agent_buf_puts(b, hdr);
|
||
|
||
int edited_lines = anchor_end - anchor_start + 1;
|
||
if (edited_lines <= EDITED_CONTEXT_HEAD + EDITED_CONTEXT_TAIL) {
|
||
for (int line = ctx_start; line <= ctx_end; line++)
|
||
agent_edit_result_append_line(b, data, &spans.v[line - 1], line);
|
||
} else {
|
||
int head_end = anchor_start + EDITED_CONTEXT_HEAD - 1;
|
||
int tail_start = anchor_end - EDITED_CONTEXT_TAIL + 1;
|
||
for (int line = ctx_start; line <= head_end; line++)
|
||
agent_edit_result_append_line(b, data, &spans.v[line - 1], line);
|
||
snprintf(hdr, sizeof(hdr),
|
||
"... %d edited lines omitted ...\n",
|
||
tail_start - head_end - 1);
|
||
agent_buf_puts(b, hdr);
|
||
for (int line = tail_start; line <= ctx_end; line++)
|
||
agent_edit_result_append_line(b, data, &spans.v[line - 1], line);
|
||
}
|
||
|
||
agent_line_spans_free(&spans);
|
||
}
|
||
|
||
static const char *agent_memmem_simple(const char *hay, size_t hay_len,
|
||
const char *needle, size_t needle_len) {
|
||
if (!needle_len) return hay;
|
||
if (needle_len > hay_len) return NULL;
|
||
size_t last = hay_len - needle_len;
|
||
for (size_t i = 0; i <= last; i++) {
|
||
if (hay[i] == needle[0] && !memcmp(hay + i, needle, needle_len))
|
||
return hay + i;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static bool agent_find_unique(const char *data, size_t len,
|
||
const char *needle, size_t needle_len,
|
||
const char **match, const char *label,
|
||
char *err, size_t err_len) {
|
||
if (!needle || needle_len == 0) {
|
||
snprintf(err, err_len, "%s anchor is empty", label);
|
||
return false;
|
||
}
|
||
const char *first = agent_memmem_simple(data, len, needle, needle_len);
|
||
if (!first) {
|
||
snprintf(err, err_len, "%s anchor not found", label);
|
||
return false;
|
||
}
|
||
size_t after_first = (size_t)(first - data) + 1;
|
||
const char *second = after_first <= len ?
|
||
agent_memmem_simple(data + after_first, len - after_first,
|
||
needle, needle_len) : NULL;
|
||
if (second) {
|
||
snprintf(err, err_len, "%s anchor is not unique", label);
|
||
return false;
|
||
}
|
||
*match = first;
|
||
return true;
|
||
}
|
||
|
||
/* Find an anchor only in the suffix after start.
|
||
*
|
||
* Anchored edits use "head [upto] tail": the head fixes the edit start, and
|
||
* the tail should delimit the first unique end point after that start. A tail
|
||
* may legitimately appear earlier in the file, so checking global uniqueness
|
||
* would reject valid edits.
|
||
*/
|
||
static bool agent_find_unique_after(const char *data, size_t len,
|
||
const char *start,
|
||
const char *needle, size_t needle_len,
|
||
const char **match, const char *label,
|
||
char *err, size_t err_len) {
|
||
if (!needle || needle_len == 0) {
|
||
snprintf(err, err_len, "%s anchor is empty", label);
|
||
return false;
|
||
}
|
||
if (start < data || start > data + len) {
|
||
snprintf(err, err_len, "%s search starts outside file", label);
|
||
return false;
|
||
}
|
||
size_t off = (size_t)(start - data);
|
||
const char *first = agent_memmem_simple(data + off, len - off,
|
||
needle, needle_len);
|
||
if (!first) {
|
||
snprintf(err, err_len, "%s anchor not found after old head", label);
|
||
return false;
|
||
}
|
||
size_t after_first = (size_t)(first - data) + 1;
|
||
const char *second = after_first <= len ?
|
||
agent_memmem_simple(data + after_first, len - after_first,
|
||
needle, needle_len) : NULL;
|
||
if (second) {
|
||
snprintf(err, err_len, "%s anchor is not unique after old head", label);
|
||
return false;
|
||
}
|
||
*match = first;
|
||
return true;
|
||
}
|
||
|
||
static bool agent_edit_old_may_be_closing_tag(const char *text, size_t len) {
|
||
size_t i = 0;
|
||
while (i < len && (text[i] == ' ' || text[i] == '\t' ||
|
||
text[i] == '\r' || text[i] == '\n'))
|
||
i++;
|
||
return i < len && text[i] == '<';
|
||
}
|
||
|
||
static bool agent_edit_old_is_only_space(const char *text, size_t len) {
|
||
for (size_t i = 0; i < len; i++) {
|
||
if (text[i] != ' ' && text[i] != '\t' &&
|
||
text[i] != '\r' && text[i] != '\n')
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static bool agent_span_has_nonspace(const char *s, size_t len) {
|
||
for (size_t i = 0; i < len; i++) {
|
||
if (!isspace((unsigned char)s[i])) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool agent_edit_old_prefix_mature_for_upto(const char *old, size_t old_len) {
|
||
if (old_len < AGENT_EDIT_UPTO_MIN_PREFIX_BYTES) return false;
|
||
int nonempty_lines = 0;
|
||
bool line_has_text = false;
|
||
for (size_t i = 0; i < old_len; i++) {
|
||
if (old[i] == '\n') {
|
||
if (line_has_text) nonempty_lines++;
|
||
line_has_text = false;
|
||
} else if (!isspace((unsigned char)old[i])) {
|
||
line_has_text = true;
|
||
}
|
||
}
|
||
return nonempty_lines >= AGENT_EDIT_UPTO_MIN_PREFIX_LINES;
|
||
}
|
||
|
||
static bool agent_edit_old_ready_for_upto(const char *old, size_t old_len) {
|
||
if (!old_len || strstr(old, "[upto]")) return false;
|
||
if (!agent_edit_old_prefix_mature_for_upto(old, old_len)) return false;
|
||
size_t end = old_len;
|
||
while (end > 0 && (old[end - 1] == ' ' || old[end - 1] == '\t' ||
|
||
old[end - 1] == '\r'))
|
||
end--;
|
||
return end > 0 && old[end - 1] == '\n';
|
||
}
|
||
|
||
/* While the model streams an edit old=... argument, stop it from retyping a
|
||
* large exact old block once the emitted prefix is already a unique file
|
||
* anchor. The next sampled token is inspected before eval: if it would keep
|
||
* writing old text rather than close the parameter, the caller evaluates a
|
||
* complete "[upto]" marker line instead. */
|
||
static bool agent_edit_upto_forcer_should_replace(agent_edit_upto_forcer *forcer,
|
||
agent_dsml_parser *p,
|
||
const char *next_text,
|
||
size_t next_len) {
|
||
if (!forcer || !p) return false;
|
||
bool in_edit_old = p->state == AGENT_DSML_PARAM_VALUE &&
|
||
p->current.name && strcmp(p->current.name, "edit") == 0 &&
|
||
p->param_name && strcmp(p->param_name, "old") == 0;
|
||
if (!in_edit_old) {
|
||
forcer->active = false;
|
||
forcer->done = false;
|
||
return false;
|
||
}
|
||
if (!forcer->active) {
|
||
forcer->active = true;
|
||
forcer->done = false;
|
||
}
|
||
if (forcer->done)
|
||
return false;
|
||
if (agent_edit_old_may_be_closing_tag(next_text, next_len) ||
|
||
agent_edit_old_is_only_space(next_text, next_len))
|
||
return false;
|
||
|
||
const char *path = agent_tool_arg_value(&p->current, "path");
|
||
if (!path || !path[0] || p->param_value_start > p->raw_len) return false;
|
||
|
||
const char *old = p->raw + p->param_value_start;
|
||
size_t old_len = p->raw_len - p->param_value_start;
|
||
if (!agent_edit_old_ready_for_upto(old, old_len)) return false;
|
||
|
||
char err[256];
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (agent_read_file_bytes(path, &data, &len, err, sizeof(err)) != 0)
|
||
return false;
|
||
|
||
const char *match = NULL;
|
||
bool unique = agent_find_unique(data, len, old, old_len, &match,
|
||
"old prefix", err, sizeof(err));
|
||
free(data);
|
||
if (unique) {
|
||
forcer->done = true;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool agent_edit_find_old_span(const char *data, size_t len,
|
||
const char *old, const char **match,
|
||
size_t *match_len, bool *anchored,
|
||
char *err, size_t err_len) {
|
||
static const char marker[] = "[upto]";
|
||
size_t old_len = strlen(old);
|
||
const char *upto = strstr(old, marker);
|
||
if (!upto) {
|
||
*anchored = false;
|
||
if (!agent_find_unique(data, len, old, old_len, match, "old text",
|
||
err, err_len))
|
||
return false;
|
||
*match_len = old_len;
|
||
return true;
|
||
}
|
||
if (strstr(upto + strlen(marker), marker)) {
|
||
snprintf(err, err_len, "old text contains more than one [upto] marker");
|
||
return false;
|
||
}
|
||
size_t head_len = (size_t)(upto - old);
|
||
const char *tail = upto + strlen(marker);
|
||
size_t tail_len = old_len - head_len - strlen(marker);
|
||
/* Strip leading newline/CR from tail before searching. The head already
|
||
* includes the newline at its end, so the extra \n that follows [upto] in
|
||
* the old text (whether injected by the forcer or written by the model)
|
||
* must not be part of the tail needle -- the file after the head has no
|
||
* duplicate newline. */
|
||
while (tail_len > 0 && (*tail == '\n' || *tail == '\r')) {
|
||
tail++;
|
||
tail_len--;
|
||
}
|
||
if (!agent_span_has_nonspace(tail, tail_len)) {
|
||
snprintf(err, err_len,
|
||
"old text after [upto] must include a unique tail anchor");
|
||
return false;
|
||
}
|
||
const char *head_pos = NULL;
|
||
const char *tail_pos = NULL;
|
||
if (!agent_find_unique(data, len, old, head_len, &head_pos, "old head",
|
||
err, err_len))
|
||
return false;
|
||
if (!agent_find_unique_after(data, len, head_pos + head_len,
|
||
tail, tail_len, &tail_pos, "old tail",
|
||
err, err_len))
|
||
return false;
|
||
*anchored = true;
|
||
*match = head_pos;
|
||
*match_len = (size_t)(tail_pos - head_pos) + tail_len;
|
||
return true;
|
||
}
|
||
|
||
#ifdef DS4_AGENT_TEST
|
||
static int agent_test_failures;
|
||
|
||
static void agent_test_assert(bool cond, const char *expr,
|
||
const char *file, int line) {
|
||
if (cond) return;
|
||
fprintf(stderr, "%s:%d: assertion failed: %s\n", file, line, expr);
|
||
agent_test_failures++;
|
||
}
|
||
|
||
#define AGENT_TEST_ASSERT(expr) \
|
||
agent_test_assert((expr), #expr, __FILE__, __LINE__)
|
||
|
||
static void test_agent_edit_upto_tail_newline_is_not_part_of_anchor(void) {
|
||
const char *data =
|
||
"CFLAGS = -Wall -Wextra -g\n"
|
||
"LDFLAGS =\n"
|
||
"\n"
|
||
"all: bc\n"
|
||
"\n"
|
||
"bc: main.c\n"
|
||
"\t$(CC) $(CFLAGS) -o bc main.c $(LDFLAGS)\n"
|
||
"\n"
|
||
"clean:\n"
|
||
"\trm -f bc\n";
|
||
const char *old =
|
||
"CFLAGS = -Wall -Wextra -g\n"
|
||
"LDFLAGS =\n"
|
||
"\n"
|
||
"all: bc\n"
|
||
"\n"
|
||
"bc: main.c\n"
|
||
"\t$(CC) $(CFLAGS) -o bc main.c $(LDFLAGS)\n"
|
||
"\n"
|
||
"[upto]\n"
|
||
"clean:\n";
|
||
|
||
const char *match = NULL;
|
||
size_t match_len = 0;
|
||
bool anchored = false;
|
||
char err[128] = {0};
|
||
AGENT_TEST_ASSERT(agent_edit_find_old_span(data, strlen(data), old,
|
||
&match, &match_len, &anchored,
|
||
err, sizeof(err)));
|
||
AGENT_TEST_ASSERT(anchored);
|
||
AGENT_TEST_ASSERT(match == data);
|
||
AGENT_TEST_ASSERT(match_len == strlen(data) - strlen("\trm -f bc\n"));
|
||
}
|
||
|
||
static void test_agent_edit_upto_requires_tail_after_newline_strip(void) {
|
||
const char *data = "head\nbody\ntail\n";
|
||
const char *old = "head\n[upto]\n";
|
||
const char *match = NULL;
|
||
size_t match_len = 0;
|
||
bool anchored = false;
|
||
char err[128] = {0};
|
||
|
||
AGENT_TEST_ASSERT(!agent_edit_find_old_span(data, strlen(data), old,
|
||
&match, &match_len, &anchored,
|
||
err, sizeof(err)));
|
||
AGENT_TEST_ASSERT(strstr(err, "must include a unique tail anchor") != NULL);
|
||
}
|
||
|
||
static void ds4_agent_unit_tests_run(void) {
|
||
test_agent_edit_upto_tail_newline_is_not_part_of_anchor();
|
||
test_agent_edit_upto_requires_tail_after_newline_strip();
|
||
}
|
||
#endif
|
||
|
||
static bool agent_preflight_edit_old(agent_worker *w, const agent_tool_call *call,
|
||
char *err, size_t err_len) {
|
||
(void)w;
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
if (!path || !path[0]) return true; /* Cannot preflight until path is known. */
|
||
|
||
const char *old = agent_tool_arg_value(call, "old");
|
||
if (!old || !old[0]) {
|
||
snprintf(err, err_len, "edit requires non-empty old text");
|
||
return false;
|
||
}
|
||
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (agent_read_file_bytes(path, &data, &len, err, err_len) != 0)
|
||
return false;
|
||
|
||
const char *match = NULL;
|
||
size_t match_len = 0;
|
||
bool anchored = false;
|
||
bool ok = agent_edit_find_old_span(data, len, old, &match, &match_len,
|
||
&anchored, err, err_len);
|
||
free(data);
|
||
return ok;
|
||
}
|
||
|
||
static char *agent_apply_file_splice(const char *path,
|
||
const char *data, size_t len,
|
||
size_t offset, size_t remove_len,
|
||
const char *insert, const char *kind) {
|
||
char err[256];
|
||
if (!insert) insert = "";
|
||
size_t insert_len = strlen(insert);
|
||
size_t out_len = offset + insert_len + (len - offset - remove_len);
|
||
char *out = xmalloc(out_len + 1);
|
||
memcpy(out, data, offset);
|
||
memcpy(out + offset, insert, insert_len);
|
||
memcpy(out + offset + insert_len, data + offset + remove_len,
|
||
len - offset - remove_len);
|
||
out[out_len] = '\0';
|
||
|
||
int rc = agent_write_file_bytes(path, out, out_len, err, sizeof(err));
|
||
if (rc != 0) {
|
||
free(out);
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: ");
|
||
agent_buf_puts(&b, err);
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
int start_line = 0, end_line = 0, delta = 0;
|
||
agent_old_new_line_effect(data, len, out, out_len, offset, remove_len,
|
||
&start_line, &end_line, &delta);
|
||
char *result = agent_edit_result(path, start_line, end_line, delta,
|
||
out, out_len, kind);
|
||
free(out);
|
||
return result;
|
||
}
|
||
|
||
/* Old/new editing is intentionally conservative: exact old text must be unique.
|
||
* For large replacements, old may contain one [upto] marker: the head must be
|
||
* unique, and the tail must be unique after that head before the whole span is
|
||
* replaced. */
|
||
static char *agent_tool_edit(agent_worker *w, const agent_tool_call *call) {
|
||
(void)w;
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
if (!path || !path[0]) return xstrdup("Tool error: edit requires path\n");
|
||
const char *old = agent_tool_arg_value(call, "old");
|
||
const char *new_text = agent_tool_arg_value(call, "new");
|
||
if (!old || !old[0]) return xstrdup("Tool error: edit requires non-empty old text\n");
|
||
if (!new_text) return xstrdup("Tool error: edit requires new text\n");
|
||
|
||
char err[256];
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (agent_read_file_bytes(path, &data, &len, err, sizeof(err)) != 0) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: ");
|
||
agent_buf_puts(&b, err);
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
const char *match = NULL;
|
||
size_t match_len = 0;
|
||
bool anchored = false;
|
||
if (!agent_edit_find_old_span(data, len, old, &match, &match_len,
|
||
&anchored, err, sizeof(err)))
|
||
{
|
||
free(data);
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: ");
|
||
agent_buf_puts(&b, err);
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
char *result = agent_apply_file_splice(path, data, len,
|
||
(size_t)(match - data), match_len,
|
||
new_text,
|
||
anchored ? "anchored old/new replacement"
|
||
: "old/new replacement");
|
||
free(data);
|
||
return result;
|
||
}
|
||
|
||
typedef struct {
|
||
const char *query;
|
||
const char *glob;
|
||
regex_t regex;
|
||
bool use_regex;
|
||
bool regex_ready;
|
||
bool case_sensitive;
|
||
int context;
|
||
int max_results;
|
||
int results;
|
||
agent_buf out;
|
||
} agent_search_ctx;
|
||
|
||
static bool agent_literal_match(const char *s, size_t n, const char *q,
|
||
bool case_sensitive) {
|
||
size_t qn = strlen(q);
|
||
if (!qn) return true;
|
||
if (qn > n) return false;
|
||
for (size_t i = 0; i + qn <= n; i++) {
|
||
bool ok = true;
|
||
for (size_t j = 0; j < qn; j++) {
|
||
unsigned char a = (unsigned char)s[i + j];
|
||
unsigned char b = (unsigned char)q[j];
|
||
if (!case_sensitive) {
|
||
a = (unsigned char)tolower(a);
|
||
b = (unsigned char)tolower(b);
|
||
}
|
||
if (a != b) {
|
||
ok = false;
|
||
break;
|
||
}
|
||
}
|
||
if (ok) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool agent_search_line_matches(agent_search_ctx *ctx, const char *s, size_t n) {
|
||
if (ctx->use_regex) {
|
||
char *line = xstrndup(s, n);
|
||
int rc = regexec(&ctx->regex, line, 0, NULL, 0);
|
||
free(line);
|
||
return rc == 0;
|
||
}
|
||
return agent_literal_match(s, n, ctx->query, ctx->case_sensitive);
|
||
}
|
||
|
||
static void agent_search_emit_line(agent_search_ctx *ctx, const char *data,
|
||
agent_line_span sp, int line_no) {
|
||
char prefix[64];
|
||
snprintf(prefix, sizeof(prefix), " %d ", line_no);
|
||
agent_buf_puts(&ctx->out, prefix);
|
||
agent_buf_append(&ctx->out, data + sp.start, sp.content_end - sp.start);
|
||
agent_buf_puts(&ctx->out, "\n");
|
||
}
|
||
|
||
/* Search one text file and emit matching lines with plain line numbers. */
|
||
static void agent_search_file(agent_search_ctx *ctx, const char *path) {
|
||
if (ctx->results >= ctx->max_results) return;
|
||
if (ctx->glob && ctx->glob[0]) {
|
||
const char *base = strrchr(path, '/');
|
||
base = base ? base + 1 : path;
|
||
if (fnmatch(ctx->glob, base, 0) != 0 && fnmatch(ctx->glob, path, 0) != 0)
|
||
return;
|
||
}
|
||
char err[256];
|
||
char *data = NULL;
|
||
size_t len = 0;
|
||
if (agent_read_file_bytes(path, &data, &len, err, sizeof(err)) != 0) return;
|
||
if (memchr(data, '\0', len)) {
|
||
free(data);
|
||
return;
|
||
}
|
||
agent_line_spans spans = {0};
|
||
agent_split_lines(data, len, &spans);
|
||
bool printed_file = false;
|
||
int last_context_line = -1;
|
||
for (int i = 0; i < spans.len && ctx->results < ctx->max_results; i++) {
|
||
agent_line_span sp = spans.v[i];
|
||
if (!agent_search_line_matches(ctx, data + sp.start, sp.content_end - sp.start))
|
||
continue;
|
||
if (!printed_file) {
|
||
agent_buf_puts(&ctx->out, path);
|
||
agent_buf_puts(&ctx->out, "\n");
|
||
printed_file = true;
|
||
}
|
||
int from = i - ctx->context;
|
||
int to = i + ctx->context;
|
||
if (from < 0) from = 0;
|
||
if (to >= spans.len) to = spans.len - 1;
|
||
if (from <= last_context_line) from = last_context_line + 1;
|
||
for (int j = from; j <= to; j++) {
|
||
agent_search_emit_line(ctx, data, spans.v[j], j + 1);
|
||
last_context_line = j;
|
||
}
|
||
ctx->results++;
|
||
}
|
||
if (printed_file) agent_buf_puts(&ctx->out, "\n");
|
||
agent_line_spans_free(&spans);
|
||
free(data);
|
||
}
|
||
|
||
/* Recursively search a file or directory, avoiding .git and stopping once the
|
||
* result cap is reached. */
|
||
static void agent_search_path(agent_search_ctx *ctx, const char *path, int depth) {
|
||
if (ctx->results >= ctx->max_results || depth > 24) return;
|
||
struct stat st;
|
||
if (lstat(path, &st) != 0) return;
|
||
if (S_ISREG(st.st_mode)) {
|
||
agent_search_file(ctx, path);
|
||
return;
|
||
}
|
||
if (!S_ISDIR(st.st_mode)) return;
|
||
DIR *dir = opendir(path);
|
||
if (!dir) return;
|
||
struct dirent *de;
|
||
while ((de = readdir(dir)) != NULL && ctx->results < ctx->max_results) {
|
||
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
|
||
if (!strcmp(de->d_name, ".git")) continue;
|
||
char child[PATH_MAX];
|
||
snprintf(child, sizeof(child), "%s/%s", path, de->d_name);
|
||
agent_search_path(ctx, child, depth + 1);
|
||
}
|
||
closedir(dir);
|
||
}
|
||
|
||
/* Implement the search tool using either literal matching or POSIX regex. */
|
||
static char *agent_tool_search(agent_worker *w, const agent_tool_call *call) {
|
||
(void)w;
|
||
const char *query = agent_tool_arg_value(call, "query");
|
||
if (!query || !query[0]) return xstrdup("Tool error: search requires query\n");
|
||
const char *path = agent_tool_arg_value(call, "path");
|
||
if (!path || !path[0]) path = ".";
|
||
const char *mode = agent_tool_arg_value(call, "mode");
|
||
agent_search_ctx ctx = {
|
||
.query = query,
|
||
.glob = agent_tool_arg_value(call, "glob"),
|
||
.use_regex = mode && !strcmp(mode, "regex"),
|
||
.case_sensitive = agent_parse_bool_default(agent_tool_arg_value(call, "case_sensitive"), true),
|
||
.context = agent_parse_int_default(agent_tool_arg_value(call, "context"), 0, 0, 5),
|
||
.max_results = agent_parse_int_default(agent_tool_arg_value(call, "max_results"), 50, 1, 500),
|
||
};
|
||
if (ctx.use_regex) {
|
||
int flags = REG_EXTENDED | REG_NOSUB;
|
||
if (!ctx.case_sensitive) flags |= REG_ICASE;
|
||
int rc = regcomp(&ctx.regex, query, flags);
|
||
if (rc != 0) {
|
||
char msg[256];
|
||
regerror(rc, &ctx.regex, msg, sizeof(msg));
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: invalid regex: ");
|
||
agent_buf_puts(&b, msg);
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
ctx.regex_ready = true;
|
||
}
|
||
agent_search_path(&ctx, path, 0);
|
||
if (ctx.regex_ready) regfree(&ctx.regex);
|
||
if (!ctx.out.ptr) agent_buf_puts(&ctx.out, "No matches\n");
|
||
else {
|
||
char hdr[96];
|
||
snprintf(hdr, sizeof(hdr), "%d match%s shown\n\n",
|
||
ctx.results, ctx.results == 1 ? "" : "es");
|
||
size_t hdr_len = strlen(hdr);
|
||
if (ctx.out.len + hdr_len + 1 > ctx.out.cap) {
|
||
ctx.out.cap = ctx.out.len + hdr_len + 1;
|
||
ctx.out.ptr = xrealloc(ctx.out.ptr, ctx.out.cap);
|
||
}
|
||
memmove(ctx.out.ptr + hdr_len, ctx.out.ptr, ctx.out.len + 1);
|
||
memcpy(ctx.out.ptr, hdr, hdr_len);
|
||
ctx.out.len += hdr_len;
|
||
}
|
||
return agent_buf_take(&ctx.out);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Browser Web Tools
|
||
* ============================================================================
|
||
*
|
||
* The browser subsystem lives in ds4_web.c: it owns visible Chrome and CDP. The
|
||
* agent side only asks for permission, dispatches tools, and caps visit_page
|
||
* output using the same "head plus temp file" shape as bash.
|
||
*/
|
||
|
||
#define AGENT_WEB_HEAD_BYTES (8*1024)
|
||
#define AGENT_WEB_HEAD_LINES 100
|
||
|
||
static int agent_count_lines(const char *s) {
|
||
if (!s || !s[0]) return 0;
|
||
int lines = 0;
|
||
for (const char *p = s; *p; p++) {
|
||
if (*p == '\n') lines++;
|
||
}
|
||
if (s[strlen(s) - 1] != '\n') lines++;
|
||
return lines;
|
||
}
|
||
|
||
static char *agent_string_head(const char *s, int max_lines, size_t max_bytes,
|
||
int *lines_read, bool *byte_limited) {
|
||
if (lines_read) *lines_read = 0;
|
||
if (byte_limited) *byte_limited = false;
|
||
if (!s) return xstrdup("");
|
||
size_t used = 0;
|
||
int lines = 0;
|
||
while (s[used] && used < max_bytes && lines < max_lines) {
|
||
if (s[used++] == '\n') lines++;
|
||
}
|
||
if (s[used] && used >= max_bytes && byte_limited) *byte_limited = true;
|
||
if (used && s[used - 1] != '\n' && lines < max_lines) lines++;
|
||
if (lines_read) *lines_read = lines;
|
||
return xstrndup(s, used);
|
||
}
|
||
|
||
static bool agent_write_temp_text(const char *prefix, const char *text,
|
||
char *path, size_t path_len,
|
||
char *err, size_t err_len) {
|
||
char tmpl[PATH_MAX];
|
||
snprintf(tmpl, sizeof(tmpl), "/tmp/%s_XXXXXX", prefix);
|
||
int fd = mkstemp(tmpl);
|
||
if (fd < 0) {
|
||
snprintf(err, err_len, "failed to create temporary file: %s", strerror(errno));
|
||
return false;
|
||
}
|
||
size_t len = text ? strlen(text) : 0;
|
||
const char *p = text ? text : "";
|
||
size_t left = len;
|
||
while (left) {
|
||
ssize_t n = write(fd, p, left);
|
||
if (n < 0 && errno == EINTR) continue;
|
||
if (n <= 0) {
|
||
snprintf(err, err_len, "failed to write temporary file: %s", strerror(errno));
|
||
close(fd);
|
||
unlink(tmpl);
|
||
return false;
|
||
}
|
||
p += n;
|
||
left -= (size_t)n;
|
||
}
|
||
if (close(fd) != 0) {
|
||
snprintf(err, err_len, "failed to close temporary file: %s", strerror(errno));
|
||
unlink(tmpl);
|
||
return false;
|
||
}
|
||
snprintf(path, path_len, "%s", tmpl);
|
||
return true;
|
||
}
|
||
|
||
static char *agent_tool_google_search(agent_worker *w, const agent_tool_call *call) {
|
||
const char *query = agent_tool_arg_value(call, "query");
|
||
if (!query || !query[0]) return xstrdup("Tool error: google_search requires query\n");
|
||
char err[256] = {0};
|
||
agent_publishf_system_status(w, "Searching Google for %s...", query);
|
||
char *md = ds4_web_google_search(w->web, query, err, sizeof(err));
|
||
if (!md) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: google_search failed: ");
|
||
agent_buf_puts(&b, err[0] ? err : "unknown error");
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
return md;
|
||
}
|
||
|
||
static char *agent_tool_visit_page(agent_worker *w, const agent_tool_call *call) {
|
||
const char *url = agent_tool_arg_value(call, "url");
|
||
if (!url || !url[0]) return xstrdup("Tool error: visit_page requires url\n");
|
||
char err[256] = {0};
|
||
agent_publishf_system_status(w, "Opening page %s...", url);
|
||
char *md = ds4_web_visit_page(w->web, url, err, sizeof(err));
|
||
if (!md) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: visit_page failed: ");
|
||
agent_buf_puts(&b, err[0] ? err : "unknown error");
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
char path[PATH_MAX];
|
||
if (!agent_write_temp_text("ds4_agent_web", md, path, sizeof(path),
|
||
err, sizeof(err)))
|
||
{
|
||
free(md);
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: visit_page failed: ");
|
||
agent_buf_puts(&b, err[0] ? err : "could not store rendered page");
|
||
agent_buf_puts(&b, "\n");
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
int total_lines = agent_count_lines(md);
|
||
int shown_lines = 0;
|
||
bool byte_limited = false;
|
||
char *head = agent_string_head(md, AGENT_WEB_HEAD_LINES, AGENT_WEB_HEAD_BYTES,
|
||
&shown_lines, &byte_limited);
|
||
bool truncated = byte_limited || shown_lines < total_lines;
|
||
agent_buf out = {0};
|
||
char line[PATH_MAX + 256];
|
||
snprintf(line, sizeof(line),
|
||
"visit_page url=%s\noutput_path=%s (%zu bytes, %d lines)\n",
|
||
url, path, strlen(md), total_lines);
|
||
agent_buf_puts(&out, line);
|
||
if (truncated) {
|
||
snprintf(line, sizeof(line), "<head -%d %s>\n",
|
||
AGENT_WEB_HEAD_LINES, path);
|
||
agent_buf_puts(&out, line);
|
||
agent_buf_puts(&out, head);
|
||
if (head[0] && head[strlen(head) - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
agent_buf_puts(&out, "</head>\n");
|
||
agent_buf_puts(&out,
|
||
"Use read path=<output_path> start_line=<line> max_lines=<count> raw=true to inspect more rendered Markdown.\n");
|
||
} else {
|
||
agent_buf_puts(&out, "<markdown>\n");
|
||
agent_buf_puts(&out, head);
|
||
if (head[0] && head[strlen(head) - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
agent_buf_puts(&out, "</markdown>\n");
|
||
}
|
||
free(head);
|
||
free(md);
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Asynchronous Bash Jobs
|
||
* ============================================================================
|
||
*
|
||
* Bash commands are tracked jobs, not blocking one-shot calls. Each job owns a
|
||
* process, a pipe, and a secure /tmp output file. The first observation is
|
||
* head-biased so headers and early errors are visible; later progress updates
|
||
* are tail-biased and report how much output was added since the previous
|
||
* observation.
|
||
*/
|
||
|
||
#define AGENT_BASH_HEAD_BYTES (8*1024)
|
||
#define AGENT_BASH_HEAD_LINES 100
|
||
#define AGENT_BASH_TAIL_BYTES (32*1024)
|
||
#define AGENT_BASH_PROGRESS_TAIL_LINES 4
|
||
#define AGENT_BASH_FINAL_TAIL_LINES 20
|
||
|
||
struct agent_bash_job {
|
||
int id;
|
||
pid_t pid;
|
||
int pipe_fd;
|
||
int tmp_fd;
|
||
char path[PATH_MAX];
|
||
char *cmd;
|
||
double start_time;
|
||
double timeout_sec;
|
||
size_t bytes;
|
||
int newline_count;
|
||
char last_byte;
|
||
size_t observed_bytes;
|
||
int observed_display_lines;
|
||
bool observed_once;
|
||
int exit_status;
|
||
bool running;
|
||
bool timed_out;
|
||
struct agent_bash_job *next;
|
||
agent_worker *worker; /* back-pointer for terminal state restoration */
|
||
};
|
||
|
||
static int agent_bash_display_lines(const agent_bash_job *job) {
|
||
if (!job || job->bytes == 0) return 0;
|
||
return job->newline_count + (job->last_byte != '\n');
|
||
}
|
||
|
||
static void agent_bash_note_output(agent_bash_job *job, const char *s, size_t n) {
|
||
for (size_t i = 0; i < n; i++) {
|
||
if (s[i] == '\n') job->newline_count++;
|
||
}
|
||
if (n) job->last_byte = s[n - 1];
|
||
job->bytes += n;
|
||
}
|
||
|
||
static void agent_bash_job_free(agent_bash_job *job) {
|
||
if (!job) return;
|
||
if (job->running && job->pid > 0) {
|
||
kill(-job->pid, SIGKILL);
|
||
kill(job->pid, SIGKILL);
|
||
waitpid(job->pid, NULL, 0);
|
||
}
|
||
if (job->pipe_fd >= 0) close(job->pipe_fd);
|
||
if (job->tmp_fd >= 0) close(job->tmp_fd);
|
||
free(job->cmd);
|
||
free(job);
|
||
}
|
||
|
||
static void agent_bash_jobs_free(agent_worker *w) {
|
||
agent_bash_job *job = w->bash_jobs;
|
||
while (job) {
|
||
agent_bash_job *next = job->next;
|
||
agent_bash_job_free(job);
|
||
job = next;
|
||
}
|
||
w->bash_jobs = NULL;
|
||
}
|
||
|
||
static agent_bash_job *agent_bash_find_job(agent_worker *w, int id, pid_t pid) {
|
||
for (agent_bash_job *job = w->bash_jobs; job; job = job->next) {
|
||
if ((id > 0 && job->id == id) || (id <= 0 && pid > 0 && job->pid == pid))
|
||
return job;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static void agent_bash_remove_job(agent_worker *w, agent_bash_job *target) {
|
||
agent_bash_job **link = &w->bash_jobs;
|
||
while (*link) {
|
||
if (*link == target) {
|
||
*link = target->next;
|
||
target->next = NULL;
|
||
agent_bash_job_free(target);
|
||
return;
|
||
}
|
||
link = &(*link)->next;
|
||
}
|
||
}
|
||
|
||
static void agent_bash_drain(agent_bash_job *job) {
|
||
if (!job || job->pipe_fd < 0) return;
|
||
char tmp[4096];
|
||
for (;;) {
|
||
ssize_t n = read(job->pipe_fd, tmp, sizeof(tmp));
|
||
if (n > 0) {
|
||
agent_bash_note_output(job, tmp, (size_t)n);
|
||
if (job->tmp_fd >= 0) write_all(job->tmp_fd, tmp, (size_t)n);
|
||
continue;
|
||
}
|
||
if (n < 0 && errno == EINTR) continue;
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void agent_worker_note_terminal_mode_may_have_changed(agent_worker *w) {
|
||
if (!w) return;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->raw_mode_needs_restore = true;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void agent_bash_finalize(agent_bash_job *job, int status) {
|
||
agent_bash_drain(job);
|
||
if (job->pipe_fd >= 0) {
|
||
close(job->pipe_fd);
|
||
job->pipe_fd = -1;
|
||
}
|
||
if (job->tmp_fd >= 0) {
|
||
close(job->tmp_fd);
|
||
job->tmp_fd = -1;
|
||
}
|
||
if (WIFEXITED(status)) job->exit_status = WEXITSTATUS(status);
|
||
else if (WIFSIGNALED(status)) job->exit_status = 128 + WTERMSIG(status);
|
||
else job->exit_status = -1;
|
||
job->running = false;
|
||
/* A child can still open /dev/tty directly and alter terminal state even
|
||
* though its stdin is /dev/null. Ask the UI thread to verify raw mode at
|
||
* a safe point instead of touching linenoise from the worker path. */
|
||
agent_worker_note_terminal_mode_may_have_changed(job->worker);
|
||
}
|
||
|
||
/* Drain available output, notice process exit, and enforce timeout. This is
|
||
* called opportunistically by status/wait/compaction instead of a background
|
||
* reaper thread, keeping all bash job state owned by the agent worker. */
|
||
static void agent_bash_poll(agent_bash_job *job) {
|
||
if (!job || !job->running) return;
|
||
agent_bash_drain(job);
|
||
|
||
int status = 0;
|
||
pid_t rc = waitpid(job->pid, &status, WNOHANG);
|
||
if (rc == job->pid) {
|
||
agent_bash_finalize(job, status);
|
||
return;
|
||
}
|
||
if (rc < 0 && errno != EINTR) {
|
||
job->exit_status = -1;
|
||
job->running = false;
|
||
if (job->pipe_fd >= 0) {
|
||
close(job->pipe_fd);
|
||
job->pipe_fd = -1;
|
||
}
|
||
if (job->tmp_fd >= 0) {
|
||
close(job->tmp_fd);
|
||
job->tmp_fd = -1;
|
||
}
|
||
agent_worker_note_terminal_mode_may_have_changed(job->worker);
|
||
return;
|
||
}
|
||
if (now_sec() - job->start_time >= job->timeout_sec) {
|
||
job->timed_out = true;
|
||
kill(-job->pid, SIGKILL);
|
||
kill(job->pid, SIGKILL);
|
||
while (waitpid(job->pid, &status, 0) < 0 && errno == EINTR) {}
|
||
agent_bash_finalize(job, status);
|
||
}
|
||
}
|
||
|
||
/* Spawn a shell command into its own process group so bash_stop/timeout can
|
||
* kill grandchildren created by the shell, not just the /bin/sh wrapper. */
|
||
static agent_bash_job *agent_bash_start(agent_worker *w, const char *cmd,
|
||
int timeout_sec, char *err, size_t err_len) {
|
||
char tmp_path[] = "/tmp/ds4_agent_output_XXXXXX";
|
||
int tmpfd = mkstemp(tmp_path);
|
||
if (tmpfd < 0) {
|
||
snprintf(err, err_len, "failed to create temporary output file: %s", strerror(errno));
|
||
return NULL;
|
||
}
|
||
|
||
int pipefd[2];
|
||
if (pipe(pipefd) != 0) {
|
||
snprintf(err, err_len, "failed to create pipe: %s", strerror(errno));
|
||
close(tmpfd);
|
||
unlink(tmp_path);
|
||
return NULL;
|
||
}
|
||
pid_t pid = fork();
|
||
if (pid < 0) {
|
||
snprintf(err, err_len, "failed to fork: %s", strerror(errno));
|
||
close(pipefd[0]);
|
||
close(pipefd[1]);
|
||
close(tmpfd);
|
||
unlink(tmp_path);
|
||
return NULL;
|
||
}
|
||
if (pid == 0) {
|
||
setpgid(0, 0);
|
||
close(tmpfd);
|
||
/* The bash tool is not interactive. Give the shell /dev/null as
|
||
* stdin so it does not inherit the live linenoise terminal and reset
|
||
* it from raw mode to cooked mode behind the agent's back. */
|
||
int null_fd = open("/dev/null", O_RDONLY);
|
||
if (null_fd >= 0) {
|
||
if (dup2(null_fd, STDIN_FILENO) < 0)
|
||
close(STDIN_FILENO);
|
||
if (null_fd != STDIN_FILENO)
|
||
close(null_fd);
|
||
} else {
|
||
close(STDIN_FILENO);
|
||
}
|
||
close(pipefd[0]);
|
||
dup2(pipefd[1], STDOUT_FILENO);
|
||
dup2(pipefd[1], STDERR_FILENO);
|
||
close(pipefd[1]);
|
||
execl("/bin/sh", "sh", "-c", cmd ? cmd : "", (char *)NULL);
|
||
_exit(127);
|
||
}
|
||
|
||
close(pipefd[1]);
|
||
setpgid(pid, pid);
|
||
int old_flags;
|
||
set_nonblock(pipefd[0], true, &old_flags);
|
||
|
||
agent_bash_job *job = xmalloc(sizeof(*job));
|
||
memset(job, 0, sizeof(*job));
|
||
if (w->next_bash_job_id <= 0) w->next_bash_job_id = 1;
|
||
job->id = w->next_bash_job_id++;
|
||
job->pid = pid;
|
||
job->pipe_fd = pipefd[0];
|
||
job->tmp_fd = tmpfd;
|
||
snprintf(job->path, sizeof(job->path), "%s", tmp_path);
|
||
job->cmd = xstrdup(cmd);
|
||
job->start_time = now_sec();
|
||
job->timeout_sec = timeout_sec;
|
||
job->exit_status = -1;
|
||
job->running = true;
|
||
job->worker = w;
|
||
job->next = w->bash_jobs;
|
||
w->bash_jobs = job;
|
||
return job;
|
||
}
|
||
|
||
static void agent_tail_append(agent_buf *b, const char *s, size_t n, size_t max) {
|
||
if (!n) return;
|
||
agent_buf_append(b, s, n);
|
||
if (b->len > max) {
|
||
size_t drop = b->len - max;
|
||
memmove(b->ptr, b->ptr + drop, b->len - drop + 1);
|
||
b->len -= drop;
|
||
}
|
||
}
|
||
|
||
/* Read the first max_lines from the output file, with a byte cap to avoid a
|
||
* pathological single long line flooding the next model turn. */
|
||
static char *agent_bash_read_head(const agent_bash_job *job, int max_lines,
|
||
size_t max_bytes, int *lines_read,
|
||
bool *byte_limited) {
|
||
if (lines_read) *lines_read = 0;
|
||
if (byte_limited) *byte_limited = false;
|
||
if (!job || !job->path[0] || job->bytes == 0) return xstrdup("");
|
||
FILE *fp = fopen(job->path, "rb");
|
||
if (!fp) return xstrdup("<failed to reopen output file>\n");
|
||
|
||
agent_buf out = {0};
|
||
int lines = 0;
|
||
while (lines < max_lines && out.len < max_bytes) {
|
||
int c = fgetc(fp);
|
||
if (c == EOF) {
|
||
if (ferror(fp) && errno == EINTR) {
|
||
clearerr(fp);
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
char ch = (char)c;
|
||
agent_buf_append(&out, &ch, 1);
|
||
if (ch == '\n') lines++;
|
||
}
|
||
if (out.len >= max_bytes && !feof(fp) && byte_limited) *byte_limited = true;
|
||
fclose(fp);
|
||
if (lines_read) *lines_read = lines + (out.len && out.ptr[out.len - 1] != '\n');
|
||
if (!out.ptr) return xstrdup("");
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
/* Read the last max_lines from the full output file. The model-visible label
|
||
* says "tail -N <file>" so it is clear this is not the complete output. */
|
||
static char *agent_bash_read_tail_lines(const agent_bash_job *job, int max_lines) {
|
||
if (!job || !job->path[0] || job->bytes == 0) return xstrdup("");
|
||
FILE *fp = fopen(job->path, "rb");
|
||
if (!fp) return xstrdup("<failed to reopen output file>\n");
|
||
|
||
agent_buf tail = {0};
|
||
char tmp[2048];
|
||
for (;;) {
|
||
size_t n = fread(tmp, 1, sizeof(tmp), fp);
|
||
if (n) agent_tail_append(&tail, tmp, n, AGENT_BASH_TAIL_BYTES);
|
||
if (n < sizeof(tmp)) {
|
||
if (ferror(fp) && errno == EINTR) {
|
||
clearerr(fp);
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
fclose(fp);
|
||
if (!tail.ptr) return xstrdup("");
|
||
|
||
char *start = tail.ptr;
|
||
int newlines = 0;
|
||
for (char *p = tail.ptr + tail.len; p > tail.ptr; p--) {
|
||
if (p[-1] == '\n' && ++newlines > max_lines) {
|
||
start = p;
|
||
break;
|
||
}
|
||
}
|
||
char *out = xstrdup(start);
|
||
free(tail.ptr);
|
||
return out;
|
||
}
|
||
|
||
/* Build the tool result for a bash job. mark_observed advances the per-job
|
||
* cursor so the next status reports only fresh output. */
|
||
static char *agent_bash_observation(agent_bash_job *job, bool mark_observed) {
|
||
agent_bash_poll(job);
|
||
bool first_observation = !job->observed_once;
|
||
int display_lines = agent_bash_display_lines(job);
|
||
double elapsed = now_sec() - job->start_time;
|
||
|
||
agent_buf out = {0};
|
||
char line[PATH_MAX + 256];
|
||
if (job->running) {
|
||
snprintf(line, sizeof(line),
|
||
"bash job=%d pid=%ld status=running elapsed_sec=%.1f timeout_sec=%.0f\n",
|
||
job->id, (long)job->pid, elapsed, job->timeout_sec);
|
||
} else {
|
||
snprintf(line, sizeof(line),
|
||
"bash job=%d pid=%ld status=done elapsed_sec=%.1f timed_out=%d\n",
|
||
job->id, (long)job->pid, elapsed, job->timed_out ? 1 : 0);
|
||
}
|
||
agent_buf_puts(&out, line);
|
||
if (!job->running) {
|
||
snprintf(line, sizeof(line), "exit_status=%d\n", job->exit_status);
|
||
agent_buf_puts(&out, line);
|
||
}
|
||
|
||
if (job->bytes == 0) {
|
||
agent_buf_puts(&out, "<output>\n</output>\n");
|
||
} else if (first_observation) {
|
||
int shown_lines = 0;
|
||
bool byte_limited = false;
|
||
char *head = agent_bash_read_head(job, AGENT_BASH_HEAD_LINES,
|
||
AGENT_BASH_HEAD_BYTES,
|
||
&shown_lines, &byte_limited);
|
||
bool truncated = byte_limited || display_lines > shown_lines;
|
||
if (!job->running && !truncated) {
|
||
agent_buf_puts(&out, "<output>\n");
|
||
agent_buf_puts(&out, head);
|
||
if (head[0] && head[strlen(head) - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
agent_buf_puts(&out, "</output>\n");
|
||
} else {
|
||
snprintf(line, sizeof(line),
|
||
"output_path=%s (%zu bytes, %d lines)\n",
|
||
job->path[0] ? job->path : "<unavailable>",
|
||
job->bytes, display_lines);
|
||
agent_buf_puts(&out, line);
|
||
snprintf(line, sizeof(line), "<head -%d %s>\n",
|
||
AGENT_BASH_HEAD_LINES, job->path);
|
||
agent_buf_puts(&out, line);
|
||
agent_buf_puts(&out, head);
|
||
if (head[0] && head[strlen(head) - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
agent_buf_puts(&out, "</head>\n");
|
||
}
|
||
free(head);
|
||
} else {
|
||
int tail_lines = job->running ? AGENT_BASH_PROGRESS_TAIL_LINES :
|
||
AGENT_BASH_FINAL_TAIL_LINES;
|
||
char *tail = agent_bash_read_tail_lines(job, tail_lines);
|
||
snprintf(line, sizeof(line),
|
||
"output_path=%s (%zu bytes, %d lines)\n",
|
||
job->path[0] ? job->path : "<unavailable>",
|
||
job->bytes, display_lines);
|
||
agent_buf_puts(&out, line);
|
||
snprintf(line, sizeof(line), "<tail -%d %s>\n", tail_lines, job->path);
|
||
agent_buf_puts(&out, line);
|
||
agent_buf_puts(&out, tail);
|
||
if (tail[0] && tail[strlen(tail) - 1] != '\n') agent_buf_puts(&out, "\n");
|
||
snprintf(line, sizeof(line), "</tail>\n");
|
||
agent_buf_puts(&out, line);
|
||
free(tail);
|
||
}
|
||
if (job->running) {
|
||
snprintf(line, sizeof(line),
|
||
"\nUse bash_status job=%d to get info before refresh time; use bash_stop job=%d to stop execution\n",
|
||
job->id, job->id);
|
||
agent_buf_puts(&out, line);
|
||
}
|
||
|
||
if (mark_observed) {
|
||
job->observed_bytes = job->bytes;
|
||
job->observed_display_lines = display_lines;
|
||
job->observed_once = true;
|
||
}
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
static void agent_bash_publish_observation(agent_worker *w, const char *obs) {
|
||
if (!obs || !obs[0]) return;
|
||
const char *body = NULL;
|
||
const char *label = strstr(obs, "\n<head ");
|
||
const char *close = NULL;
|
||
if (label) {
|
||
close = "</head>";
|
||
} else {
|
||
label = strstr(obs, "\n<tail ");
|
||
if (label) close = "</tail>";
|
||
}
|
||
if (label) {
|
||
const char *tag_end = strstr(label, ">\n");
|
||
if (tag_end) {
|
||
agent_publish(w, "\x1b[90m", 5);
|
||
if (strstr(label, "\n<head ") == label)
|
||
agent_publish(w, "[showing first output lines]\n",
|
||
strlen("[showing first output lines]\n"));
|
||
else
|
||
agent_publish(w, "[showing last output lines]\n",
|
||
strlen("[showing last output lines]\n"));
|
||
agent_publish(w, "\x1b[0m", 4);
|
||
body = tag_end + 2;
|
||
}
|
||
} else {
|
||
label = strstr(obs, "\n<output>\n");
|
||
if (label) {
|
||
body = label + strlen("\n<output>\n");
|
||
close = "</output>";
|
||
}
|
||
}
|
||
if (!body || !body[0]) return;
|
||
const char *end = close ? strstr(body, close) : NULL;
|
||
size_t n = end ? (size_t)(end - body) : strlen(body);
|
||
if (n) {
|
||
bool failed = strstr(obs, "status=done") && !strstr(obs, "exit_status=0\n");
|
||
if (failed) agent_publish(w, "\x1b[38;5;208m", 11);
|
||
agent_publish(w, body, n);
|
||
if (body[n - 1] != '\n') agent_publish(w, "\n", 1);
|
||
if (failed) agent_publish(w, "\x1b[0m", 4);
|
||
}
|
||
}
|
||
|
||
static void agent_bash_refresh_for(agent_worker *w, agent_bash_job *job,
|
||
int refresh_sec) {
|
||
double start = now_sec();
|
||
while (job->running && now_sec() - start < refresh_sec) {
|
||
if (worker_should_interrupt(w)) break;
|
||
agent_bash_poll(job);
|
||
if (!job->running) break;
|
||
struct pollfd pfd = {.fd = job->pipe_fd, .events = POLLIN};
|
||
poll(&pfd, 1, 100);
|
||
}
|
||
agent_bash_poll(job);
|
||
}
|
||
|
||
/* Common implementation for bash, bash_status, and bash_stop. */
|
||
static char *agent_bash_job_tool_result(agent_worker *w, agent_bash_job *job,
|
||
bool wait, int refresh_sec,
|
||
bool stop, bool remove_if_done) {
|
||
if (stop && job->running) {
|
||
kill(-job->pid, SIGTERM);
|
||
kill(job->pid, SIGTERM);
|
||
double start = now_sec();
|
||
while (job->running && now_sec() - start < 1.0) {
|
||
agent_bash_poll(job);
|
||
if (!job->running) break;
|
||
usleep(20000);
|
||
}
|
||
if (job->running) {
|
||
kill(-job->pid, SIGKILL);
|
||
kill(job->pid, SIGKILL);
|
||
}
|
||
}
|
||
if (wait || stop) agent_bash_refresh_for(w, job, refresh_sec);
|
||
else agent_bash_poll(job);
|
||
|
||
char *obs = agent_bash_observation(job, true);
|
||
agent_bash_publish_observation(w, obs);
|
||
if (remove_if_done && !job->running) agent_bash_remove_job(w, job);
|
||
return obs;
|
||
}
|
||
|
||
static int agent_tool_job_id(const agent_tool_call *call) {
|
||
return agent_parse_int_default(agent_tool_arg_value(call, "job"), 0, 0, INT_MAX);
|
||
}
|
||
|
||
static pid_t agent_tool_pid(const agent_tool_call *call) {
|
||
return (pid_t)agent_parse_int_default(agent_tool_arg_value(call, "pid"), 0, 0, INT_MAX);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Tool Dispatch
|
||
* ============================================================================
|
||
*/
|
||
|
||
/* Execute one parsed DSML tool call and return the text that will be appended as
|
||
* the tool-role result. UI visualization already happened while streaming; this
|
||
* function is only about side effects and the model-visible observation. */
|
||
static char *agent_execute_tool_call(agent_worker *w, const agent_tool_call *call) {
|
||
agent_buf result = {0};
|
||
if (!call->name) return xstrdup("Tool error: missing tool name\n");
|
||
|
||
if (!strcmp(call->name, "read")) return agent_tool_read(w, call);
|
||
if (!strcmp(call->name, "more")) return agent_tool_more(w, call);
|
||
if (!strcmp(call->name, "write")) return agent_tool_write(w, call);
|
||
if (!strcmp(call->name, "list")) return agent_tool_list(call);
|
||
if (!strcmp(call->name, "edit")) return agent_tool_edit(w, call);
|
||
if (!strcmp(call->name, "search")) return agent_tool_search(w, call);
|
||
if (!strcmp(call->name, "google_search")) return agent_tool_google_search(w, call);
|
||
if (!strcmp(call->name, "visit_page")) return agent_tool_visit_page(w, call);
|
||
|
||
if (!strcmp(call->name, "bash")) {
|
||
const char *cmd = agent_tool_arg_value(call, "command");
|
||
if (!cmd || !cmd[0]) return xstrdup("Tool error: bash requires command\n");
|
||
int timeout = agent_parse_timeout(agent_tool_arg_value(call, "timeout_sec"));
|
||
int refresh = agent_parse_int_default(agent_tool_arg_value(call, "refresh_sec"),
|
||
60, 1, 3600);
|
||
char err[160] = {0};
|
||
agent_bash_job *job = agent_bash_start(w, cmd, timeout, err, sizeof(err));
|
||
if (!job) {
|
||
agent_buf_puts(&result, "Tool error: bash failed to start: ");
|
||
agent_buf_puts(&result, err[0] ? err : "unknown error");
|
||
agent_buf_puts(&result, "\n");
|
||
return agent_buf_take(&result);
|
||
}
|
||
return agent_bash_job_tool_result(w, job, true, refresh, false, true);
|
||
}
|
||
|
||
if (!strcmp(call->name, "bash_status") ||
|
||
!strcmp(call->name, "bash_stop"))
|
||
{
|
||
int job_id = agent_tool_job_id(call);
|
||
pid_t pid = agent_tool_pid(call);
|
||
agent_bash_job *job = agent_bash_find_job(w, job_id, pid);
|
||
if (!job) {
|
||
char msg[128];
|
||
snprintf(msg, sizeof(msg), "Tool error: bash job not found: job=%d pid=%ld\n",
|
||
job_id, (long)pid);
|
||
return xstrdup(msg);
|
||
}
|
||
int refresh = agent_parse_int_default(agent_tool_arg_value(call, "refresh_sec"),
|
||
60, 1, 3600);
|
||
bool stop = !strcmp(call->name, "bash_stop");
|
||
bool wait = stop;
|
||
return agent_bash_job_tool_result(w, job, wait, refresh, stop, true);
|
||
}
|
||
|
||
{
|
||
char header[256];
|
||
snprintf(header, sizeof(header), "\n[tool:%s] unknown tool\n", call->name);
|
||
agent_publish(w, header, strlen(header));
|
||
agent_buf_puts(&result, "Tool error: unknown tool: ");
|
||
agent_buf_puts(&result, call->name);
|
||
agent_buf_puts(&result, "\n");
|
||
return agent_buf_take(&result);
|
||
}
|
||
}
|
||
|
||
/* Execute all tool calls from one DSML block, preserving per-call labels in the
|
||
* combined result so the model can associate observations with calls. */
|
||
static char *agent_execute_tool_calls(agent_worker *w, const agent_tool_calls *calls) {
|
||
agent_buf all = {0};
|
||
for (int i = 0; i < calls->len; i++) {
|
||
char *res = agent_execute_tool_call(w, &calls->v[i]);
|
||
char hdr[128];
|
||
snprintf(hdr, sizeof(hdr), "Tool result %d (%s):\n", i + 1,
|
||
calls->v[i].name ? calls->v[i].name : "unknown");
|
||
agent_buf_puts(&all, hdr);
|
||
agent_buf_puts(&all, res);
|
||
if (res[0] && res[strlen(res) - 1] != '\n') agent_buf_puts(&all, "\n");
|
||
free(res);
|
||
}
|
||
if (calls->len == 0) agent_buf_puts(&all, "Tool error: empty tool call block\n");
|
||
return agent_buf_take(&all);
|
||
}
|
||
|
||
/* If compaction happens while a bash process is still alive, inject a small
|
||
* tool-role reminder into the rebuilt transcript. Otherwise the summary could
|
||
* preserve the user's task but lose the fact that an external process still
|
||
* needs status/wait/stop handling. */
|
||
static char *agent_bash_jobs_compaction_observation(agent_worker *w) {
|
||
if (!w->bash_jobs) return NULL;
|
||
agent_buf out = {0};
|
||
agent_buf_puts(&out,
|
||
"Bash job update after context compaction. Running jobs still need explicit bash_status or bash_stop if relevant.\n");
|
||
for (agent_bash_job *job = w->bash_jobs, *next = NULL; job; job = next) {
|
||
next = job->next;
|
||
char *obs = agent_bash_observation(job, true);
|
||
char hdr[64];
|
||
snprintf(hdr, sizeof(hdr), "\nJob %d:\n", job->id);
|
||
agent_buf_puts(&out, hdr);
|
||
agent_buf_puts(&out, obs);
|
||
free(obs);
|
||
if (!job->running) agent_bash_remove_job(w, job);
|
||
}
|
||
return agent_buf_take(&out);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Context Compaction
|
||
* ============================================================================
|
||
*
|
||
* Compaction asks the model for durable task state, then rebuilds the live
|
||
* transcript as: system prompt + summary + recent verbatim tail. This keeps
|
||
* the active KV usable while avoiding unbounded transcript growth.
|
||
*/
|
||
|
||
/* Decide when to compact before an ordinary turn or before appending a large
|
||
* tool result. The fixed free-token threshold is capped proportionally for
|
||
* smaller contexts so tests with tiny contexts still compact rather than fail. */
|
||
static bool agent_worker_should_compact(agent_worker *w) {
|
||
int ctx = w->cfg->gen.ctx_size;
|
||
int used = w->transcript.len;
|
||
if (ctx <= 0 || used <= 0) return false;
|
||
if (used >= (ctx * AGENT_COMPACT_SOFT_PERCENT) / 100) return true;
|
||
int free_threshold = AGENT_COMPACT_MIN_FREE_TOKENS;
|
||
int proportional = ctx / 4;
|
||
if (free_threshold > proportional) free_threshold = proportional;
|
||
return ctx - used <= free_threshold;
|
||
}
|
||
|
||
static int agent_special_token_id(ds4_engine *engine, const char *rendered) {
|
||
ds4_tokens t = {0};
|
||
ds4_tokenize_rendered_chat(engine, rendered, &t);
|
||
int id = t.len == 1 ? t.v[0] : -1;
|
||
ds4_tokens_free(&t);
|
||
return id;
|
||
}
|
||
|
||
/* Pick a recent verbatim tail for the compacted transcript. Prefer a user
|
||
* boundary inside the budget so the rebuilt context starts at a natural turn. */
|
||
static int agent_compact_tail_start(agent_worker *w, int bottom, int sys_len) {
|
||
int tail_budget = w->cfg->gen.ctx_size / AGENT_COMPACT_TAIL_DIVISOR;
|
||
if (tail_budget > AGENT_COMPACT_TAIL_CAP_TOKENS)
|
||
tail_budget = AGENT_COMPACT_TAIL_CAP_TOKENS;
|
||
if (tail_budget < 1) tail_budget = 1;
|
||
|
||
int target = bottom - tail_budget;
|
||
if (target < sys_len) target = sys_len;
|
||
|
||
int user_id = agent_special_token_id(w->engine, "<|User|>");
|
||
if (user_id < 0) return target;
|
||
|
||
for (int i = target; i < bottom; i++) {
|
||
if (w->transcript.v[i] == user_id) return i;
|
||
}
|
||
return target;
|
||
}
|
||
|
||
static void agent_tokens_append_range(ds4_tokens *dst, const ds4_tokens *src,
|
||
int start, int end) {
|
||
if (start < 0) start = 0;
|
||
if (end > src->len) end = src->len;
|
||
for (int i = start; i < end; i++) ds4_tokens_push(dst, src->v[i]);
|
||
}
|
||
|
||
/* Build the private prompt used to ask the model for durable state. The prompt
|
||
* explicitly forbids tool calls because the result is consumed internally, not
|
||
* delivered as an assistant turn. */
|
||
static char *agent_compact_make_prompt(const char *reason) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b,
|
||
"Internal ds4-agent context compaction request. This is not a user request.\n"
|
||
"Write a durable task-state summary of the conversation so far. Preserve only facts that matter for continuing the work:\n"
|
||
"- user goals, constraints, and preferences\n"
|
||
"- files inspected or edited\n"
|
||
"- commands run and important results\n"
|
||
"- decisions, rejected approaches, known bugs, and pending next steps\n"
|
||
"- reloadable bulky data with exact paths/ranges/commands when available\n\n"
|
||
"Do not invent facts. Do not include generic narration. Do not include raw file contents unless they were essential to a conclusion.\n"
|
||
"After the summary, stop. Do not continue the user task, do not call tools, and do not output thinking tags or DSML markup.\n"
|
||
"Output only the compact summary.\n");
|
||
if (reason && reason[0]) {
|
||
agent_buf_puts(&b, "\nCompaction reason: ");
|
||
agent_buf_puts(&b, reason);
|
||
agent_buf_puts(&b, "\n");
|
||
}
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
/* Perform the full compaction exchange and rebuild the live DS4 session from
|
||
* the compacted transcript. Any failure invalidates live KV because the model
|
||
* may have just seen private compaction instructions that are not part of the
|
||
* real conversation. */
|
||
static bool agent_worker_compact(agent_worker *w, const char *reason,
|
||
char *err, size_t err_len) {
|
||
const int bottom = w->transcript.len;
|
||
if (bottom <= 0) return true;
|
||
|
||
ds4_tokens sys = {0};
|
||
agent_worker_build_system_tokens(w, &sys);
|
||
if (bottom <= sys.len) {
|
||
ds4_tokens_free(&sys);
|
||
return true;
|
||
}
|
||
|
||
agent_publishf(w,
|
||
"\n\x1b[1;95mCOMPACTING\x1b[0m %s: summarizing durable task state\n\x1b[38;5;245m",
|
||
reason && reason[0] ? reason : "context");
|
||
|
||
char *prompt_text = agent_compact_make_prompt(reason);
|
||
ds4_tokens prompt = {0};
|
||
ds4_tokens_copy(&prompt, &w->transcript);
|
||
ds4_chat_append_message(w->engine, &prompt, "user", prompt_text);
|
||
free(prompt_text);
|
||
ds4_chat_append_assistant_prefix(w->engine, &prompt, DS4_THINK_NONE);
|
||
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.state = AGENT_WORKER_COMPACTING;
|
||
w->progress_started_at = now_sec();
|
||
w->status.prefill_done = 0;
|
||
w->status.prefill_total = 0;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.generated = 0;
|
||
w->status.gen_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
int summary_room = w->cfg->gen.ctx_size - prompt.len - 1;
|
||
if (summary_room < 256) {
|
||
snprintf(err, err_len, "not enough context left to request compaction summary");
|
||
ds4_tokens_free(&prompt);
|
||
ds4_tokens_free(&sys);
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
return false;
|
||
}
|
||
int summary_max = summary_room < AGENT_COMPACT_SUMMARY_MAX_TOKENS ?
|
||
summary_room : AGENT_COMPACT_SUMMARY_MAX_TOKENS;
|
||
|
||
ds4_session_set_progress(w->session, worker_progress_cb, w);
|
||
ds4_session_set_display_progress(w->session, worker_progress_cb, w);
|
||
ds4_session_set_cancel(w->session, worker_cancel_session_cb, w);
|
||
int sync_rc = ds4_session_sync(w->session, &prompt, err, err_len);
|
||
ds4_session_set_cancel(w->session, NULL, NULL);
|
||
ds4_session_set_progress(w->session, NULL, NULL);
|
||
ds4_session_set_display_progress(w->session, NULL, NULL);
|
||
if (sync_rc == DS4_SESSION_SYNC_INTERRUPTED) {
|
||
ds4_session_invalidate(w->session);
|
||
snprintf(err, err_len, "interrupted");
|
||
agent_publish_system_status(
|
||
w, "Compaction interrupted; keeping the previous conversation state.");
|
||
ds4_tokens_free(&prompt);
|
||
ds4_tokens_free(&sys);
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
worker_clear_interrupt(w);
|
||
return false;
|
||
}
|
||
if (sync_rc != 0) {
|
||
ds4_session_invalidate(w->session);
|
||
ds4_tokens_free(&prompt);
|
||
ds4_tokens_free(&sys);
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
return false;
|
||
}
|
||
|
||
/* From here until the final rebuild, the live KV contains the internal
|
||
* compaction prompt/summary, while w->transcript still contains the real
|
||
* conversation. If anything fails, invalidate live KV so the next turn
|
||
* cannot accidentally continue from the private compaction exchange. */
|
||
agent_buf summary = {0};
|
||
char eval_err[160] = {0};
|
||
int think_end_id = agent_special_token_id(w->engine, "</think>");
|
||
int dsml_id = agent_special_token_id(w->engine, "|DSML|");
|
||
double t0 = now_sec();
|
||
for (int i = 0; i < summary_max; i++) {
|
||
if (worker_should_interrupt(w)) {
|
||
snprintf(err, err_len, "interrupted");
|
||
ds4_session_invalidate(w->session);
|
||
ds4_tokens_free(&prompt);
|
||
ds4_tokens_free(&sys);
|
||
free(summary.ptr);
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
agent_publish_system_status(
|
||
w, "Compaction interrupted; keeping the previous conversation state.");
|
||
worker_clear_interrupt(w);
|
||
return false;
|
||
}
|
||
int token = ds4_session_argmax(w->session);
|
||
if (token == ds4_token_eos(w->engine)) break;
|
||
if (token == think_end_id || token == dsml_id) {
|
||
if (token == dsml_id && summary.len && summary.ptr[summary.len - 1] == '<') {
|
||
summary.ptr[--summary.len] = '\0';
|
||
}
|
||
agent_trace(w, "compaction summary stopped before control token id=%d", token);
|
||
break;
|
||
}
|
||
if (ds4_session_eval(w->session, token, eval_err, sizeof(eval_err)) != 0) {
|
||
snprintf(err, err_len, "%s", eval_err);
|
||
ds4_session_invalidate(w->session);
|
||
ds4_tokens_free(&prompt);
|
||
ds4_tokens_free(&sys);
|
||
free(summary.ptr);
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
return false;
|
||
}
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_token_text(w->engine, token, &text_len);
|
||
agent_buf_append(&summary, text, text_len);
|
||
agent_publish(w, text, text_len);
|
||
free(text);
|
||
|
||
double dt = now_sec() - t0;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.generated = i + 1;
|
||
w->status.gen_tps = dt > 0.0 ? (double)(i + 1) / dt : 0.0;
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
agent_publish(w, "\x1b[0m\n", 5);
|
||
ds4_tokens_free(&prompt);
|
||
|
||
if (!summary.ptr || !summary.ptr[0]) {
|
||
snprintf(err, err_len, "compaction summary was empty");
|
||
ds4_session_invalidate(w->session);
|
||
ds4_tokens_free(&sys);
|
||
free(summary.ptr);
|
||
return false;
|
||
}
|
||
|
||
int tail_start = agent_compact_tail_start(w, bottom, sys.len);
|
||
ds4_tokens compacted = {0};
|
||
ds4_tokens_copy(&compacted, &sys);
|
||
|
||
agent_buf summary_msg = {0};
|
||
agent_buf_puts(&summary_msg,
|
||
"\n\n[ds4-agent compacted earlier conversation. Durable task-state summary follows.]\n");
|
||
agent_buf_puts(&summary_msg, summary.ptr);
|
||
if (summary_msg.len && summary_msg.ptr[summary_msg.len - 1] != '\n')
|
||
agent_buf_puts(&summary_msg, "\n");
|
||
agent_buf_puts(&summary_msg, "[End compacted summary. Recent conversation continues verbatim below.]\n\n");
|
||
ds4_chat_append_message(w->engine, &compacted, "system", summary_msg.ptr);
|
||
free(summary_msg.ptr);
|
||
free(summary.ptr);
|
||
|
||
agent_tokens_append_range(&compacted, &w->transcript, tail_start, bottom);
|
||
|
||
agent_publishf(w,
|
||
"\x1b[1;95mCOMPACTING\x1b[0m rebuilding context: old=%d summary+tail=%d tail=%d\n",
|
||
bottom, compacted.len, bottom - tail_start);
|
||
|
||
ds4_tokens old_transcript = {0};
|
||
ds4_tokens_copy(&old_transcript, &w->transcript);
|
||
ds4_tokens_free(&w->transcript);
|
||
w->transcript = compacted;
|
||
if (agent_worker_sync_tokens(w, &w->transcript, true, err, err_len) != 0) {
|
||
ds4_session_invalidate(w->session);
|
||
ds4_tokens_free(&w->transcript);
|
||
w->transcript = old_transcript;
|
||
ds4_tokens_free(&sys);
|
||
return false;
|
||
}
|
||
agent_worker_note_system_prompt_seen(w);
|
||
ds4_tokens_free(&old_transcript);
|
||
ds4_tokens_free(&sys);
|
||
char *bash_update = agent_bash_jobs_compaction_observation(w);
|
||
if (bash_update) {
|
||
ds4_chat_append_message(w->engine, &w->transcript, "tool", bash_update);
|
||
w->session_dirty = true;
|
||
agent_trace_text(w, "tool-after-compaction", bash_update, strlen(bash_update));
|
||
agent_publish(w, "\x1b[90mCOMPACTING added bash job update after rebuild\x1b[0m\n",
|
||
strlen("\x1b[90mCOMPACTING added bash job update after rebuild\x1b[0m\n"));
|
||
free(bash_update);
|
||
}
|
||
agent_trace(w, "compacted reason=\"%s\" old=%d new=%d tail_start=%d tail=%d",
|
||
reason ? reason : "", bottom, w->transcript.len,
|
||
tail_start, bottom - tail_start);
|
||
return true;
|
||
}
|
||
|
||
static bool agent_worker_compact_if_needed(agent_worker *w, const char *reason,
|
||
char *err, size_t err_len) {
|
||
if (!agent_worker_should_compact(w)) return true;
|
||
return agent_worker_compact(w, reason, err, err_len);
|
||
}
|
||
|
||
static int worker_accept_generated_token(agent_worker *w,
|
||
int token,
|
||
int *generated,
|
||
double t0,
|
||
agent_stream_renderer *stream,
|
||
char *err,
|
||
size_t err_len) {
|
||
if (ds4_session_eval(w->session, token, err, err_len) != 0)
|
||
return 1;
|
||
|
||
ds4_tokens_push(&w->transcript, token);
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_token_text(w->engine, token, &text_len);
|
||
agent_trace_token(w, token, text, text_len, *generated + 1);
|
||
agent_stream_text(stream, text, text_len, false);
|
||
free(text);
|
||
(*generated)++;
|
||
|
||
double dt = now_sec() - t0;
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.generated = *generated;
|
||
w->status.gen_tps = dt > 0.0 ? (double)*generated / dt : 0.0;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
return 0;
|
||
}
|
||
|
||
static int worker_force_generated_text(agent_worker *w,
|
||
const char *text,
|
||
int max_tokens,
|
||
int *generated,
|
||
double t0,
|
||
agent_stream_renderer *stream,
|
||
char *err,
|
||
size_t err_len) {
|
||
ds4_tokens tokens = {0};
|
||
ds4_tokenize_text(w->engine, text, &tokens);
|
||
if (tokens.len > max_tokens - *generated) {
|
||
snprintf(err, err_len, "not enough generation room to force %s", text);
|
||
ds4_tokens_free(&tokens);
|
||
return 1;
|
||
}
|
||
for (int i = 0; i < tokens.len && *generated < max_tokens; i++) {
|
||
if (worker_accept_generated_token(w, tokens.v[i], generated, t0,
|
||
stream, err, err_len) != 0) {
|
||
ds4_tokens_free(&tokens);
|
||
return 1;
|
||
}
|
||
}
|
||
ds4_tokens_free(&tokens);
|
||
return 0;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Model Worker Thread
|
||
* ============================================================================
|
||
*/
|
||
|
||
/* DSML structure is a machine-readable grammar, so once the model has clearly
|
||
* started a tool stanza we decode grammar bytes greedily. Parameter values are
|
||
* different: they can be shell commands, code, file contents, or edit bodies,
|
||
* and should keep the configured sampling behavior. The only exception is a
|
||
* parameter closing tag once it is clearly DSML syntax, not ordinary text such
|
||
* as HTML/XML/code containing "</".
|
||
*
|
||
* This helper is intentionally derived only from the current streaming parser
|
||
* state. The state object is local to one assistant round, so malformed output,
|
||
* EOS, Ctrl+C, or the next turn cannot accidentally leave sampling greedy. */
|
||
static bool agent_stream_wants_greedy_sampling(const agent_stream_renderer *sr) {
|
||
if (!sr || !sr->parser) return false;
|
||
if (sr->parser->state == AGENT_DSML_ERROR ||
|
||
sr->parser->state == AGENT_DSML_DONE)
|
||
return false;
|
||
|
||
/* A possible opening marker is being held back by the start detector. A
|
||
* single '<' is too common in prose/code to justify forcing argmax; after
|
||
* the second byte, the buffered prefix still matching here is specifically
|
||
* DSML-shaped ("<|..." or the tolerated "<D..." typo). */
|
||
if (sr->dsml_start_len > 1) return true;
|
||
if (!sr->dsml_active) return false;
|
||
|
||
if (sr->parser->state == AGENT_DSML_STRUCTURAL)
|
||
return true;
|
||
if (sr->parser->state != AGENT_DSML_PARAM_VALUE)
|
||
return false;
|
||
|
||
return sr->parser->param_close_prefix;
|
||
}
|
||
|
||
static int worker_sample_with_mode(agent_worker *w, const agent_config *cfg,
|
||
bool greedy, uint64_t *rng) {
|
||
return ds4_session_sample(w->session,
|
||
greedy ? 0.0f : cfg->gen.temperature,
|
||
0,
|
||
greedy ? 1.0f : cfg->gen.top_p,
|
||
greedy ? 0.0f : cfg->gen.min_p,
|
||
rng);
|
||
}
|
||
|
||
static void worker_set_greedy_sampling(agent_worker *w, bool greedy) {
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->status.greedy_sampling != greedy) {
|
||
w->status.greedy_sampling = greedy;
|
||
agent_wake_locked(w);
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* Run one user turn until the assistant stops or returns a tool call. Tool
|
||
* results are appended to the transcript and the loop continues, which gives
|
||
* the model native DSML tool iteration without a client/server protocol. */
|
||
static int worker_run_turn(agent_worker *w, const char *user_text) {
|
||
agent_config *cfg = w->cfg;
|
||
ds4_think_mode think_mode = effective_think_mode(cfg);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->interrupt = false;
|
||
w->status.error[0] = '\0';
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
char compact_err[160] = {0};
|
||
if (!agent_worker_compact_if_needed(w, "soft limit before user turn",
|
||
compact_err, sizeof(compact_err)))
|
||
{
|
||
if (agent_err_is_interrupted(compact_err)) {
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed");
|
||
return 1;
|
||
}
|
||
agent_worker_maybe_append_datetime_context(w);
|
||
agent_trace_text(w, "user", user_text ? user_text : "",
|
||
user_text ? strlen(user_text) : 0);
|
||
if (!w->session_title) {
|
||
w->session_title = agent_session_title_from_prompt(user_text, 0);
|
||
w->session_created_at = (uint64_t)time(NULL);
|
||
agent_session_identity_sha(w->session_title, w->session_created_at,
|
||
w->session_sha);
|
||
}
|
||
ds4_chat_append_message(w->engine, &w->transcript, "user", user_text);
|
||
|
||
uint64_t rng = cfg->gen.seed ? cfg->gen.seed :
|
||
((uint64_t)time(NULL) ^ ((uint64_t)getpid() << 32) ^ (uint64_t)clock());
|
||
pthread_mutex_lock(&w->mu);
|
||
w->user_activity = true;
|
||
w->session_dirty = true;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
/* A user turn may contain any number of assistant/tool/assistant rounds.
|
||
* Coding agents naturally perform long read/edit/test loops, so there is
|
||
* deliberately no artificial "too many tool calls" ceiling here: context
|
||
* pressure, compaction, user Ctrl+C, and the model's final answer are the
|
||
* real stopping conditions. The transcript is the single source of truth:
|
||
* after a DSML stanza completes we terminate that assistant message, append
|
||
* the tool result as a tool message, then ask the model to continue. */
|
||
for (int tool_round = 0; ; tool_round++) {
|
||
if (tool_round > 0 &&
|
||
!agent_worker_compact_if_needed(w, "soft limit before tool continuation",
|
||
compact_err, sizeof(compact_err)))
|
||
{
|
||
if (agent_err_is_interrupted(compact_err)) {
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed");
|
||
return 1;
|
||
}
|
||
agent_worker_maybe_append_system_prompt_reminder(w);
|
||
ds4_chat_append_assistant_prefix(w->engine, &w->transcript, think_mode);
|
||
|
||
const ds4_tokens *prompt_for_sync = &w->transcript;
|
||
int old_pos = ds4_session_pos(w->session);
|
||
int common = ds4_session_common_prefix(w->session, &w->transcript);
|
||
int cached = common == old_pos && w->transcript.len >= old_pos ? common : 0;
|
||
|
||
int suffix = prompt_for_sync->len - cached;
|
||
agent_trace(w, "prefill tool_round=%d transcript=%d prompt=%d cached=%d suffix=%d think=%s",
|
||
tool_round, w->transcript.len, prompt_for_sync->len,
|
||
cached, suffix, ds4_think_mode_name(think_mode));
|
||
agent_trace_tokens(w, "prefill_suffix", prompt_for_sync, cached);
|
||
|
||
pthread_mutex_lock(&w->mu);
|
||
unsigned prefill_label = w->status.state == AGENT_WORKER_PREFILL ?
|
||
w->status.prefill_label : agent_next_prefill_label();
|
||
w->status.state = AGENT_WORKER_PREFILL;
|
||
w->progress_base = cached;
|
||
w->progress_started_at = now_sec();
|
||
w->status.prefill_done = 0;
|
||
w->status.prefill_total = suffix;
|
||
w->status.prefill_label = prefill_label;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.generated = 0;
|
||
w->status.gen_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
char err[160];
|
||
ds4_session_set_progress(w->session, worker_progress_cb, w);
|
||
ds4_session_set_display_progress(w->session, worker_progress_cb, w);
|
||
ds4_session_set_cancel(w->session, worker_cancel_session_cb, w);
|
||
int sync_rc = ds4_session_sync(w->session, prompt_for_sync, err, sizeof(err));
|
||
ds4_session_set_cancel(w->session, NULL, NULL);
|
||
ds4_session_set_progress(w->session, NULL, NULL);
|
||
ds4_session_set_display_progress(w->session, NULL, NULL);
|
||
if (sync_rc == DS4_SESSION_SYNC_INTERRUPTED) {
|
||
agent_publish_system_status(
|
||
w, "Model reading interrupted; the model may only be aware of the prefix processed so far.");
|
||
ds4_tokens_push(&w->transcript, ds4_token_eos(w->engine));
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
if (sync_rc != 0) {
|
||
agent_set_error(w, err);
|
||
return 1;
|
||
}
|
||
|
||
int max_tokens = cfg->gen.n_predict;
|
||
int room = ds4_session_ctx(w->session) - ds4_session_pos(w->session);
|
||
if (room <= 1) max_tokens = 0;
|
||
else if (max_tokens > room - 1) max_tokens = room - 1;
|
||
|
||
bool use_color = isatty(STDOUT_FILENO) != 0;
|
||
agent_token_renderer renderer = {
|
||
.engine = w->engine,
|
||
.worker = w,
|
||
.format_thinking = ds4_think_mode_enabled(think_mode),
|
||
.format_markdown = use_color,
|
||
.in_think = ds4_think_mode_enabled(think_mode),
|
||
.use_color = use_color,
|
||
.last_output_newline = true,
|
||
};
|
||
agent_dsml_parser dsml = {.state = AGENT_DSML_SEARCH};
|
||
agent_stream_renderer stream = {
|
||
.renderer = &renderer,
|
||
.parser = &dsml,
|
||
.in_think = ds4_think_mode_enabled(think_mode),
|
||
};
|
||
agent_edit_upto_forcer upto_forcer = {0};
|
||
bool got_tool = false;
|
||
bool malformed_tool = false;
|
||
bool early_tool_error = false;
|
||
int generated = 0;
|
||
double t0 = now_sec();
|
||
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.state = AGENT_WORKER_GENERATING;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
bool status_greedy_sampling = false;
|
||
while (generated < max_tokens && !worker_should_interrupt(w)) {
|
||
worker_apply_pending_power(w);
|
||
bool greedy_sampling = agent_stream_wants_greedy_sampling(&stream);
|
||
if (greedy_sampling != status_greedy_sampling) {
|
||
worker_set_greedy_sampling(w, greedy_sampling);
|
||
status_greedy_sampling = greedy_sampling;
|
||
}
|
||
int token = worker_sample_with_mode(w, cfg, greedy_sampling, &rng);
|
||
if (token == ds4_token_eos(w->engine)) break;
|
||
|
||
size_t text_len = 0;
|
||
char *text = ds4_token_text(w->engine, token, &text_len);
|
||
if (agent_edit_upto_forcer_should_replace(&upto_forcer, &dsml,
|
||
text, text_len))
|
||
{
|
||
agent_trace(w, "edit old auto-upto replaced token=%d text=%.*s",
|
||
token, (int)(text_len > 80 ? 80 : text_len), text);
|
||
free(text);
|
||
if (worker_force_generated_text(w, "[upto]\n", max_tokens,
|
||
&generated, t0, &stream,
|
||
err, sizeof(err)) != 0) {
|
||
agent_dsml_parser_free(&dsml);
|
||
agent_set_error(w, err);
|
||
return 1;
|
||
}
|
||
} else {
|
||
free(text);
|
||
if (worker_accept_generated_token(w, token, &generated, t0,
|
||
&stream, err, sizeof(err)) != 0) {
|
||
agent_dsml_parser_free(&dsml);
|
||
agent_set_error(w, err);
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
greedy_sampling = agent_stream_wants_greedy_sampling(&stream);
|
||
if (greedy_sampling != status_greedy_sampling) {
|
||
worker_set_greedy_sampling(w, greedy_sampling);
|
||
status_greedy_sampling = greedy_sampling;
|
||
}
|
||
|
||
if (dsml.state == AGENT_DSML_DONE) {
|
||
got_tool = true;
|
||
break;
|
||
}
|
||
if (stream.tool_preflight_error) {
|
||
early_tool_error = true;
|
||
break;
|
||
}
|
||
if (dsml.state == AGENT_DSML_ERROR) {
|
||
malformed_tool = true;
|
||
break;
|
||
}
|
||
if (stream.dsml_in_think) {
|
||
malformed_tool = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
bool interrupted = worker_should_interrupt(w);
|
||
agent_stream_text(&stream, NULL, 0, true);
|
||
renderer_finish(&renderer);
|
||
worker_set_greedy_sampling(w, false);
|
||
if (interrupted) {
|
||
ds4_tokens_push(&w->transcript, ds4_token_eos(w->engine));
|
||
agent_dsml_parser_free(&dsml);
|
||
agent_publish_system_status(w, "Stopped by user");
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
if (stream.dsml_in_think) {
|
||
got_tool = false;
|
||
malformed_tool = true;
|
||
early_tool_error = false;
|
||
snprintf(dsml.error, sizeof(dsml.error),
|
||
"tool calling is not allowed inside <think></think>");
|
||
} else if (!malformed_tool && dsml.state == AGENT_DSML_ERROR) {
|
||
malformed_tool = true;
|
||
} else if (!got_tool && !malformed_tool && !early_tool_error &&
|
||
!interrupted &&
|
||
(dsml.state == AGENT_DSML_STRUCTURAL ||
|
||
dsml.state == AGENT_DSML_PARAM_VALUE))
|
||
{
|
||
malformed_tool = true;
|
||
snprintf(dsml.error, sizeof(dsml.error),
|
||
"incomplete DSML tool call");
|
||
}
|
||
|
||
ds4_tokens_push(&w->transcript, ds4_token_eos(w->engine));
|
||
|
||
if (!got_tool && !malformed_tool && !early_tool_error) {
|
||
agent_dsml_parser_free(&dsml);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
|
||
char *tool_result;
|
||
if (early_tool_error) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: ");
|
||
agent_buf_puts(&b, stream.tool_preflight_error_msg[0] ?
|
||
stream.tool_preflight_error_msg :
|
||
"edit old selector failed before new was generated");
|
||
agent_buf_puts(&b, "\n");
|
||
tool_result = agent_buf_take(&b);
|
||
} else if (malformed_tool) {
|
||
agent_buf b = {0};
|
||
agent_buf_puts(&b, "Tool error: invalid DSML tool call: ");
|
||
agent_buf_puts(&b, dsml.error[0] ? dsml.error : "parse error");
|
||
agent_buf_puts(&b, "\n");
|
||
agent_buf_puts(&b, agent_dsml_syntax_reminder);
|
||
tool_result = agent_buf_take(&b);
|
||
} else {
|
||
tool_result = agent_execute_tool_calls(w, &dsml.calls);
|
||
}
|
||
int projected_tokens = 0;
|
||
if (!agent_tool_result_fits_context(w, tool_result,
|
||
AGENT_TOOL_RESULT_RESERVE_TOKENS,
|
||
&projected_tokens))
|
||
{
|
||
if (!agent_worker_compact(w, "tool result would exceed context",
|
||
compact_err, sizeof(compact_err)))
|
||
{
|
||
free(tool_result);
|
||
agent_dsml_parser_free(&dsml);
|
||
if (agent_err_is_interrupted(compact_err)) {
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return 0;
|
||
}
|
||
agent_set_error(w, compact_err[0] ? compact_err : "context compaction failed");
|
||
return 1;
|
||
}
|
||
if (!agent_tool_result_fits_context(w, tool_result,
|
||
AGENT_TOOL_RESULT_RESERVE_TOKENS,
|
||
&projected_tokens))
|
||
{
|
||
free(tool_result);
|
||
agent_buf b = {0};
|
||
char msg[256];
|
||
snprintf(msg, sizeof(msg),
|
||
"Tool error: tool result still does not fit after context compaction "
|
||
"(projected_prompt=%d tokens, ctx=%d, reserve=%d). "
|
||
"Retry with a smaller read/search/bash output.\n",
|
||
projected_tokens, w->cfg->gen.ctx_size,
|
||
AGENT_TOOL_RESULT_RESERVE_TOKENS);
|
||
agent_buf_puts(&b, msg);
|
||
tool_result = agent_buf_take(&b);
|
||
if (!agent_tool_result_fits_context(w, tool_result, 16, NULL)) {
|
||
free(tool_result);
|
||
agent_dsml_parser_free(&dsml);
|
||
agent_set_error(w, "context full after compaction");
|
||
return 1;
|
||
}
|
||
}
|
||
}
|
||
ds4_chat_append_message(w->engine, &w->transcript, "tool", tool_result);
|
||
free(tool_result);
|
||
agent_dsml_parser_free(&dsml);
|
||
|
||
char *queued_user = worker_request_queued_user_drain(w);
|
||
if (queued_user && queued_user[0]) {
|
||
agent_trace_text(w, "queued_user", queued_user, strlen(queued_user));
|
||
ds4_chat_append_message(w->engine, &w->transcript, "user", queued_user);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->user_activity = true;
|
||
w->session_dirty = true;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
free(queued_user);
|
||
}
|
||
}
|
||
|
||
static void worker_request_save(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->save_requested = true;
|
||
pthread_cond_signal(&w->cond);
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void worker_request_compact(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->compact_requested = true;
|
||
pthread_cond_signal(&w->cond);
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void worker_request_power(agent_worker *w, int power) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->requested_power = power;
|
||
w->power_requested = true;
|
||
w->status.power_percent = power;
|
||
pthread_cond_signal(&w->cond);
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static bool worker_take_save_requested(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool requested = w->save_requested;
|
||
w->save_requested = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return requested;
|
||
}
|
||
|
||
static bool worker_take_compact_requested(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool requested = w->compact_requested;
|
||
w->compact_requested = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return requested;
|
||
}
|
||
|
||
static bool worker_take_power_requested(agent_worker *w, int *power) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool requested = w->power_requested;
|
||
if (requested) {
|
||
if (power) *power = w->requested_power;
|
||
w->power_requested = false;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
return requested;
|
||
}
|
||
|
||
static void worker_apply_pending_power(agent_worker *w) {
|
||
int power = 0;
|
||
if (!worker_take_power_requested(w, &power)) return;
|
||
if (ds4_session_set_power(w->session, power) != 0) {
|
||
agent_publishf(w, "\npower change failed\n");
|
||
return;
|
||
}
|
||
pthread_mutex_lock(&w->mu);
|
||
w->cfg->engine.power_percent = power;
|
||
w->status.power_percent = power;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void worker_run_deferred_save(agent_worker *w) {
|
||
if (!worker_take_save_requested(w)) return;
|
||
agent_set_status(w, AGENT_WORKER_SAVING);
|
||
char err[160] = {0};
|
||
char sha[41];
|
||
int tokens = 0;
|
||
if (agent_worker_save_session_now(w, sha, &tokens, err, sizeof(err)))
|
||
agent_publishf(w, "\nsaved session %.8s (%d tokens)\n", sha, tokens);
|
||
else
|
||
agent_publishf(w, "\nsave failed: %s\n", err[0] ? err : "unknown error");
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
}
|
||
|
||
static void worker_run_deferred_compact(agent_worker *w) {
|
||
if (!worker_take_compact_requested(w)) return;
|
||
if (!agent_worker_has_user_session(w)) {
|
||
agent_publishf(w, "\ncompact skipped: nothing to compact\n");
|
||
return;
|
||
}
|
||
|
||
int before = w->transcript.len;
|
||
char err[160] = {0};
|
||
if (agent_worker_compact(w, "user requested compaction", err, sizeof(err))) {
|
||
if (w->transcript.len != before) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->session_dirty = true;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
} else {
|
||
agent_publishf(w, "\ncompact skipped: nothing to compact\n");
|
||
}
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
} else {
|
||
if (agent_err_is_interrupted(err)) {
|
||
worker_clear_interrupt(w);
|
||
agent_set_status(w, AGENT_WORKER_IDLE);
|
||
return;
|
||
}
|
||
agent_set_error(w, err[0] ? err : "context compaction failed");
|
||
}
|
||
}
|
||
|
||
/* Worker thread entry point. The UI thread submits plain user text; this
|
||
* thread owns all DS4 session mutation, tool execution, and compaction. */
|
||
static void *worker_main(void *arg) {
|
||
agent_worker *w = arg;
|
||
agent_trace(w, "agent worker start ctx=%d backend=%s model=%s trace=%s",
|
||
w->cfg->gen.ctx_size,
|
||
ds4_backend_name(w->cfg->engine.backend),
|
||
w->cfg->engine.model_path ? w->cfg->engine.model_path : "",
|
||
w->cfg->gen.trace_path ? w->cfg->gen.trace_path : "");
|
||
char init_err[160] = {0};
|
||
if (!agent_worker_wait_distributed_route(w, init_err, sizeof(init_err)) ||
|
||
!agent_worker_reset_to_sysprompt(w, init_err, sizeof(init_err))) {
|
||
agent_set_error(w, init_err[0] ? init_err : "failed to initialize system prompt");
|
||
}
|
||
agent_trace_tokens(w, "initial_system_prompt", &w->transcript, 0);
|
||
pthread_mutex_lock(&w->mu);
|
||
w->initialized = true;
|
||
agent_wake_locked(w);
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
while (true) {
|
||
pthread_mutex_lock(&w->mu);
|
||
while (!w->stop && !w->cmd_text && !w->save_requested &&
|
||
!w->compact_requested && !w->power_requested)
|
||
pthread_cond_wait(&w->cond, &w->mu);
|
||
if (w->stop) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
break;
|
||
}
|
||
if (w->power_requested) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
worker_apply_pending_power(w);
|
||
continue;
|
||
}
|
||
if (!w->cmd_text && w->save_requested) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
worker_run_deferred_save(w);
|
||
continue;
|
||
}
|
||
if (!w->cmd_text && w->compact_requested) {
|
||
pthread_mutex_unlock(&w->mu);
|
||
worker_run_deferred_compact(w);
|
||
continue;
|
||
}
|
||
char *cmd = w->cmd_text;
|
||
w->cmd_text = NULL;
|
||
pthread_mutex_unlock(&w->mu);
|
||
|
||
worker_run_turn(w, cmd);
|
||
free(cmd);
|
||
worker_apply_pending_power(w);
|
||
worker_run_deferred_compact(w);
|
||
worker_run_deferred_save(w);
|
||
}
|
||
|
||
agent_set_status(w, AGENT_WORKER_STOPPED);
|
||
return NULL;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Worker/UI Synchronization Helpers
|
||
* ============================================================================
|
||
*/
|
||
|
||
static int set_nonblock(int fd, bool on, int *old_flags) {
|
||
int flags = fcntl(fd, F_GETFL, 0);
|
||
if (flags < 0) return -1;
|
||
if (old_flags) *old_flags = flags;
|
||
int next = on ? (flags | O_NONBLOCK) : (flags & ~O_NONBLOCK);
|
||
return fcntl(fd, F_SETFL, next);
|
||
}
|
||
|
||
/* Check and clear the raw_mode_needs_restore flag under the worker mutex.
|
||
* Returns true if the UI thread should verify/reapply linenoise raw mode. */
|
||
static bool worker_check_raw_mode_restore(agent_worker *w) {
|
||
bool needs = false;
|
||
pthread_mutex_lock(&w->mu);
|
||
if (w->raw_mode_needs_restore) {
|
||
w->raw_mode_needs_restore = false;
|
||
needs = true;
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
return needs;
|
||
}
|
||
|
||
static void drain_wake_fd(int fd) {
|
||
char buf[128];
|
||
for (;;) {
|
||
ssize_t n = read(fd, buf, sizeof(buf));
|
||
if (n > 0) continue;
|
||
if (n < 0 && errno == EINTR) continue;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Submit one user turn if the worker is idle. Busy submissions are rejected so
|
||
* the UI can keep the typed text editable instead of silently queueing it. */
|
||
static bool worker_submit(agent_worker *w, const char *text) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool ok = w->initialized && w->status.state == AGENT_WORKER_IDLE && !w->cmd_text;
|
||
if (ok) {
|
||
w->cmd_text = xstrdup(text);
|
||
/* A submitted turn is no longer idle, even if the worker thread has
|
||
* not yet reached its real prefill accounting. Non-interactive mode
|
||
* depends on this to avoid exiting in the small handoff window between
|
||
* accepting stdin and starting generation. */
|
||
w->status.state = AGENT_WORKER_PREFILL;
|
||
w->status.prefill_done = 0;
|
||
w->status.prefill_total = 0;
|
||
w->status.prefill_label = agent_next_prefill_label();
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.generated = 0;
|
||
w->status.gen_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
pthread_cond_signal(&w->cond);
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
return ok;
|
||
}
|
||
|
||
static int worker_status_power_locked(agent_worker *w) {
|
||
if (w->power_requested) return w->requested_power;
|
||
int power = w->cfg->engine.power_percent;
|
||
return power > 0 ? power : 100;
|
||
}
|
||
|
||
/* Request interruption at the next model/tool polling point. */
|
||
static void worker_interrupt(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->interrupt = true;
|
||
if (w->cfg &&
|
||
w->cfg->engine.distributed.role == DS4_DISTRIBUTED_COORDINATOR &&
|
||
(w->status.state == AGENT_WORKER_PREFILL ||
|
||
w->status.state == AGENT_WORKER_GENERATING ||
|
||
w->status.state == AGENT_WORKER_COMPACTING))
|
||
{
|
||
w->status.state = AGENT_WORKER_DRAINING;
|
||
w->status.prefill_tps = 0.0;
|
||
w->status.greedy_sampling = false;
|
||
agent_wake_locked(w);
|
||
}
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* Stop the worker thread. */
|
||
static void worker_stop(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->stop = true;
|
||
pthread_cond_signal(&w->cond);
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
/* The UI thread consumes output in batches. Taking ownership of w->out under
|
||
* the mutex keeps terminal writes outside the lock while preserving order. */
|
||
static void worker_consume(agent_worker *w, char **out, size_t *out_len, agent_status *status) {
|
||
pthread_mutex_lock(&w->mu);
|
||
if (out) {
|
||
*out = w->out;
|
||
*out_len = w->out_len;
|
||
w->out = NULL;
|
||
w->out_len = 0;
|
||
w->out_cap = 0;
|
||
}
|
||
w->status.ctx_used = w->transcript.len;
|
||
w->status.ctx_size = w->cfg->gen.ctx_size;
|
||
w->status.power_percent = worker_status_power_locked(w);
|
||
if (status) *status = w->status;
|
||
w->wake_pending = false;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static void worker_get_status(agent_worker *w, agent_status *status) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.ctx_used = w->transcript.len;
|
||
w->status.ctx_size = w->cfg->gen.ctx_size;
|
||
w->status.power_percent = worker_status_power_locked(w);
|
||
*status = w->status;
|
||
pthread_mutex_unlock(&w->mu);
|
||
}
|
||
|
||
static bool worker_is_idle(agent_worker *w) {
|
||
pthread_mutex_lock(&w->mu);
|
||
bool idle = w->initialized &&
|
||
(w->status.state == AGENT_WORKER_IDLE ||
|
||
w->status.state == AGENT_WORKER_ERROR);
|
||
pthread_mutex_unlock(&w->mu);
|
||
return idle;
|
||
}
|
||
|
||
static bool worker_is_initialized(agent_worker *w, agent_status *status) {
|
||
pthread_mutex_lock(&w->mu);
|
||
w->status.ctx_used = w->transcript.len;
|
||
w->status.ctx_size = w->cfg->gen.ctx_size;
|
||
w->status.power_percent = worker_status_power_locked(w);
|
||
if (status) *status = w->status;
|
||
bool initialized = w->initialized;
|
||
pthread_mutex_unlock(&w->mu);
|
||
return initialized;
|
||
}
|
||
|
||
static bool stdout_is_tty(void) {
|
||
return isatty(STDOUT_FILENO) != 0;
|
||
}
|
||
|
||
static char *agent_format_user_prompt_echo(const char *text) {
|
||
agent_buf b = {0};
|
||
if (stdout_is_tty()) {
|
||
agent_buf_puts(&b, "\x1b[1;91m*\x1b[1;97m ");
|
||
agent_buf_puts(&b, text);
|
||
agent_buf_puts(&b, "\x1b[0m\n\n");
|
||
} else {
|
||
agent_buf_puts(&b, "* ");
|
||
agent_buf_puts(&b, text);
|
||
agent_buf_puts(&b, "\n\n");
|
||
}
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static void agent_echo_user_prompt(const char *text) {
|
||
char *msg = agent_format_user_prompt_echo(text);
|
||
printf("%s", msg);
|
||
fflush(stdout);
|
||
free(msg);
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Terminal Prompt, Status Footer, And Async Output Rendering
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void agent_format_ctx_size(int ctx_size, char *buf, size_t len);
|
||
#define AGENT_INPUT_INITIAL_BUFLEN 4096
|
||
#define AGENT_INPUT_MAX_BUFLEN (1024*1024)
|
||
#define AGENT_STATUS_STYLE_START "\x1b[48;5;238;38;5;252m"
|
||
#define AGENT_STATUS_STYLE_END "\x1b[0m"
|
||
#define AGENT_STATUS_BAR_FILL "\x1b[48;5;238;38;5;201;1m"
|
||
#define AGENT_QUEUE_STYLE "\x1b[38;5;87;1m"
|
||
#define AGENT_STATUS_REDRAW_INTERVAL_SEC 0.20
|
||
#define AGENT_PROGRESS_BAR_WIDTH 32
|
||
#define AGENT_PROGRESS_BAR_MAX_BYTES 256
|
||
|
||
static void agent_progress_append(char *buf, size_t len, size_t *pos,
|
||
const char *s) {
|
||
if (len == 0 || *pos >= len - 1) return;
|
||
size_t avail = len - *pos;
|
||
int n = snprintf(buf + *pos, avail, "%s", s);
|
||
if (n <= 0) return;
|
||
if ((size_t)n >= avail) *pos = len - 1;
|
||
else *pos += (size_t)n;
|
||
}
|
||
|
||
static void build_prompt_text(const agent_status *st, char *buf, size_t len) {
|
||
(void)st;
|
||
snprintf(buf, len, "ds4-agent> ");
|
||
}
|
||
|
||
static void agent_progress_bar(int done, int total, double tps,
|
||
char *buf, size_t len, bool color) {
|
||
if (len == 0) return;
|
||
if (total <= 0) total = 1;
|
||
if (done < 0) done = 0;
|
||
if (done > total) done = total;
|
||
int filled = (int)(((long long)done * AGENT_PROGRESS_BAR_WIDTH) / total);
|
||
if (filled < 0) filled = 0;
|
||
if (filled > AGENT_PROGRESS_BAR_WIDTH) filled = AGENT_PROGRESS_BAR_WIDTH;
|
||
if (color && filled == 0 && done < total) filled = 1;
|
||
char rate[32] = {0};
|
||
size_t rate_len = 0;
|
||
if (tps > 0.0 && filled < AGENT_PROGRESS_BAR_WIDTH) {
|
||
snprintf(rate, sizeof(rate), " %.0ft/s", tps);
|
||
rate_len = strlen(rate);
|
||
}
|
||
size_t pos = 0;
|
||
agent_progress_append(buf, len, &pos, "[");
|
||
if (color) agent_progress_append(buf, len, &pos, AGENT_STATUS_BAR_FILL);
|
||
for (int i = 0; i < AGENT_PROGRESS_BAR_WIDTH && pos + 1 < len; i++) {
|
||
if (color && i == filled) {
|
||
agent_progress_append(buf, len, &pos, AGENT_STATUS_STYLE_START);
|
||
}
|
||
if (i >= filled && rate_len > 0 && (size_t)(i - filled) < rate_len) {
|
||
char ch[2] = {rate[i - filled], '\0'};
|
||
agent_progress_append(buf, len, &pos, ch);
|
||
} else {
|
||
agent_progress_append(buf, len, &pos, i < filled ? "▶" : "·");
|
||
}
|
||
}
|
||
if (color) agent_progress_append(buf, len, &pos, AGENT_STATUS_STYLE_START);
|
||
agent_progress_append(buf, len, &pos, "]");
|
||
buf[pos < len ? pos : len - 1] = '\0';
|
||
}
|
||
|
||
static void agent_power_status_suffix(const agent_status *st,
|
||
char *buf, size_t len) {
|
||
if (len == 0) return;
|
||
if (st->power_percent > 0 && st->power_percent < 100)
|
||
snprintf(buf, len, " | ⚡ %d%%", st->power_percent);
|
||
else
|
||
buf[0] = '\0';
|
||
}
|
||
|
||
static unsigned agent_next_prefill_label(void) {
|
||
static unsigned next;
|
||
return next++;
|
||
}
|
||
|
||
/* Keep each prefill operation on a single playful label so the footer does not
|
||
* visually churn while progress updates stream in. */
|
||
static const char *agent_prefill_label(const agent_status *st) {
|
||
static const char *labels[] = {
|
||
"reading",
|
||
"absorbing",
|
||
"studying",
|
||
"gathering",
|
||
"crunching",
|
||
"scrutinizing",
|
||
};
|
||
size_t n = sizeof(labels) / sizeof(labels[0]);
|
||
return labels[(st ? st->prefill_label : 0u) % n];
|
||
}
|
||
|
||
/* Build the one-line footer shown below the prompt. It is intentionally compact
|
||
* because linenoise redraws it on every progress update. */
|
||
static void build_status_text(const agent_status *st, char *buf, size_t len) {
|
||
char used[32], total_ctx[32];
|
||
char power[32];
|
||
agent_format_ctx_size(st->ctx_used, used, sizeof(used));
|
||
agent_format_ctx_size(st->ctx_size, total_ctx, sizeof(total_ctx));
|
||
agent_power_status_suffix(st, power, sizeof(power));
|
||
|
||
switch (st->state) {
|
||
case AGENT_WORKER_PREFILL: {
|
||
int done = st->prefill_done;
|
||
int total = st->prefill_total > 0 ? st->prefill_total : 1;
|
||
if (done > total) done = total;
|
||
double pct = 100.0 * (double)done / (double)total;
|
||
char bar[AGENT_PROGRESS_BAR_MAX_BYTES];
|
||
agent_progress_bar(done, total, st->prefill_tps, bar, sizeof(bar),
|
||
stdout_is_tty());
|
||
snprintf(buf, len, "ctx %s/%s | %s %s %d/%d %.1f%%%s",
|
||
used, total_ctx, agent_prefill_label(st), bar,
|
||
done, total, pct, power);
|
||
break;
|
||
}
|
||
case AGENT_WORKER_GENERATING:
|
||
snprintf(buf, len, "ctx %s/%s | generation %d tokens%s %.1f t/s%s",
|
||
used, total_ctx, st->generated,
|
||
st->greedy_sampling ? " ❄️" : "", st->gen_tps, power);
|
||
break;
|
||
case AGENT_WORKER_COMPACTING:
|
||
snprintf(buf, len, "ctx %s/%s | COMPACTING summary %d tokens %.1f t/s%s",
|
||
used, total_ctx, st->generated, st->gen_tps, power);
|
||
break;
|
||
case AGENT_WORKER_DRAINING:
|
||
snprintf(buf, len, "ctx %s/%s | stopping after distributed cluster drains%s",
|
||
used, total_ctx, power);
|
||
break;
|
||
case AGENT_WORKER_SAVING:
|
||
snprintf(buf, len, "ctx %s/%s | saving session%s", used, total_ctx, power);
|
||
break;
|
||
case AGENT_WORKER_ERROR:
|
||
snprintf(buf, len, "ctx %s/%s | error: %s%s", used, total_ctx,
|
||
st->error[0] ? st->error : "unknown error", power);
|
||
break;
|
||
case AGENT_WORKER_STOPPED:
|
||
snprintf(buf, len, "ctx %s/%s | interrupted%s", used, total_ctx, power);
|
||
break;
|
||
default:
|
||
snprintf(buf, len, "ctx %s/%s | idle%s", used, total_ctx, power);
|
||
break;
|
||
}
|
||
}
|
||
|
||
typedef struct {
|
||
char **v;
|
||
size_t len;
|
||
size_t cap;
|
||
} agent_prompt_queue;
|
||
|
||
static void agent_prompt_queue_push(agent_prompt_queue *q, const char *text) {
|
||
if (q->len == q->cap) {
|
||
q->cap = q->cap ? q->cap * 2 : 4;
|
||
q->v = xrealloc(q->v, q->cap * sizeof(q->v[0]));
|
||
}
|
||
q->v[q->len++] = xstrdup(text ? text : "");
|
||
}
|
||
|
||
static char *agent_prompt_queue_pop(agent_prompt_queue *q) {
|
||
if (!q->len) return NULL;
|
||
char *text = q->v[0];
|
||
memmove(q->v, q->v + 1, (q->len - 1) * sizeof(q->v[0]));
|
||
q->len--;
|
||
return text;
|
||
}
|
||
|
||
static void agent_prompt_queue_push_front(agent_prompt_queue *q, char *text) {
|
||
if (q->len == q->cap) {
|
||
q->cap = q->cap ? q->cap * 2 : 4;
|
||
q->v = xrealloc(q->v, q->cap * sizeof(q->v[0]));
|
||
}
|
||
memmove(q->v + 1, q->v, q->len * sizeof(q->v[0]));
|
||
q->v[0] = text;
|
||
q->len++;
|
||
}
|
||
|
||
static char *agent_prompt_queue_take_all(agent_prompt_queue *q) {
|
||
if (!q->len) return NULL;
|
||
if (q->len == 1) return agent_prompt_queue_pop(q);
|
||
|
||
agent_buf b = {0};
|
||
for (size_t i = 0; i < q->len; i++) {
|
||
char hdr[64];
|
||
if (i) agent_buf_puts(&b, "\n\n");
|
||
snprintf(hdr, sizeof(hdr), "Queued user message %zu:\n", i + 1);
|
||
agent_buf_puts(&b, hdr);
|
||
agent_buf_puts(&b, q->v[i]);
|
||
free(q->v[i]);
|
||
}
|
||
q->len = 0;
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static char *agent_prompt_queue_take_all_echo(agent_prompt_queue *q) {
|
||
if (!q->len) return NULL;
|
||
agent_buf b = {0};
|
||
for (size_t i = 0; i < q->len; i++) {
|
||
char *echo = agent_format_user_prompt_echo(q->v[i]);
|
||
agent_buf_puts(&b, echo);
|
||
free(echo);
|
||
}
|
||
return agent_buf_take(&b);
|
||
}
|
||
|
||
static const char *agent_prompt_queue_peek(const agent_prompt_queue *q) {
|
||
return q->len ? q->v[0] : NULL;
|
||
}
|
||
|
||
static void agent_prompt_queue_free(agent_prompt_queue *q) {
|
||
for (size_t i = 0; i < q->len; i++) free(q->v[i]);
|
||
free(q->v);
|
||
memset(q, 0, sizeof(*q));
|
||
}
|
||
|
||
static bool agent_footer_is_multiline(const char *status) {
|
||
return status && strchr(status, '\n');
|
||
}
|
||
|
||
/* Build the editable footer. With queued prompts, the footer becomes multiple
|
||
* rows: a compact queue preview first, then the normal status row. */
|
||
static void build_footer_text(const agent_status *st, const agent_prompt_queue *queue,
|
||
int cols, char *buf, size_t len) {
|
||
char status[512];
|
||
build_status_text(st, status, sizeof(status));
|
||
if (!queue || !queue->len) {
|
||
snprintf(buf, len, "%s", status);
|
||
return;
|
||
}
|
||
|
||
const char *queued = agent_prompt_queue_peek(queue);
|
||
if (cols < 40) cols = 40;
|
||
int max_rows = 3;
|
||
size_t budget = (size_t)cols * (size_t)max_rows;
|
||
const char *plain_suffix = " (ctrl+x to edit, ESC to send ASAP)";
|
||
size_t queued_len = strlen(queued);
|
||
char more_suffix[160];
|
||
const char *suffix = plain_suffix;
|
||
size_t take = queued_len;
|
||
if (queued_len + strlen(plain_suffix) > budget) {
|
||
size_t reserve = 72;
|
||
take = budget > reserve ? budget - reserve : budget / 2;
|
||
snprintf(more_suffix, sizeof(more_suffix),
|
||
"... %zu characters more ..., (ctrl+x to edit, ESC to send ASAP)",
|
||
queued_len - take);
|
||
suffix = more_suffix;
|
||
}
|
||
|
||
agent_buf msg = {0};
|
||
agent_buf_puts(&msg, "queued: ");
|
||
for (size_t i = 0; i < take; i++) {
|
||
char c = queued[i];
|
||
if (c == '\n' || c == '\r' || c == '\t') c = ' ';
|
||
agent_buf_append(&msg, &c, 1);
|
||
}
|
||
agent_buf_puts(&msg, suffix);
|
||
char *preview = agent_buf_take(&msg);
|
||
|
||
agent_buf out = {0};
|
||
size_t pos = 0, preview_len = strlen(preview);
|
||
for (int row = 0; row < max_rows && pos < preview_len; row++) {
|
||
if (row) agent_buf_puts(&out, "\n");
|
||
if (stdout_is_tty()) agent_buf_puts(&out, AGENT_QUEUE_STYLE);
|
||
size_t part = preview_len - pos;
|
||
if (part > (size_t)cols) part = (size_t)cols;
|
||
agent_buf_append(&out, preview + pos, part);
|
||
if (stdout_is_tty()) agent_buf_puts(&out, "\x1b[0m");
|
||
pos += part;
|
||
}
|
||
agent_buf_puts(&out, "\n");
|
||
if (stdout_is_tty()) agent_buf_puts(&out, AGENT_STATUS_STYLE_START);
|
||
agent_buf_puts(&out, status);
|
||
snprintf(buf, len, "%s", out.ptr ? out.ptr : "");
|
||
free(preview);
|
||
free(out.ptr);
|
||
}
|
||
|
||
typedef struct {
|
||
struct linenoiseState edit;
|
||
char *input;
|
||
char prompt[160];
|
||
char status[4096];
|
||
int old_stdin_flags;
|
||
bool active;
|
||
bool hidden;
|
||
bool output_line_open;
|
||
bool prompt_below_output;
|
||
int output_col;
|
||
bool scroll_region;
|
||
int term_rows;
|
||
int term_cols;
|
||
int output_bottom;
|
||
int prompt_row;
|
||
int reserved_rows;
|
||
bool output_cursor_saved;
|
||
bool output_at_scroll_boundary;
|
||
double last_prompt_redraw_time;
|
||
char cpr_buf[32];
|
||
size_t cpr_len;
|
||
bool paste_open;
|
||
bool paste_start_pending;
|
||
char paste_tail[6];
|
||
size_t paste_tail_len;
|
||
} agent_editor;
|
||
|
||
static void editor_queue_bytes(agent_editor *ed, const char *buf, size_t len);
|
||
static void editor_hide(agent_editor *ed);
|
||
static void editor_show(agent_editor *ed);
|
||
|
||
typedef enum {
|
||
CPR_INVALID,
|
||
CPR_PARTIAL,
|
||
CPR_COMPLETE,
|
||
} cpr_state;
|
||
|
||
/* Classify a possible terminal cursor-position reply (ESC[row;colR). User
|
||
* keystrokes can arrive interleaved with these replies, so we only swallow bytes
|
||
* when they are definitely part of a complete CPR sequence. */
|
||
static cpr_state cpr_candidate_state(const char *buf, size_t len) {
|
||
if (len == 0) return CPR_PARTIAL;
|
||
if ((unsigned char)buf[0] != 0x1b) return CPR_INVALID;
|
||
if (len == 1) return CPR_PARTIAL;
|
||
if (buf[1] != '[') return CPR_INVALID;
|
||
if (len == 2) return CPR_PARTIAL;
|
||
|
||
size_t p = 2;
|
||
if (buf[p] < '0' || buf[p] > '9') return CPR_INVALID;
|
||
while (p < len && buf[p] >= '0' && buf[p] <= '9') p++;
|
||
if (p == len) return CPR_PARTIAL;
|
||
if (buf[p++] != ';') return CPR_INVALID;
|
||
if (p == len) return CPR_PARTIAL;
|
||
if (buf[p] < '0' || buf[p] > '9') return CPR_INVALID;
|
||
while (p < len && buf[p] >= '0' && buf[p] <= '9') p++;
|
||
if (p == len) return CPR_PARTIAL;
|
||
return p + 1 == len && buf[p] == 'R' ? CPR_COMPLETE : CPR_INVALID;
|
||
}
|
||
|
||
static void editor_flush_cpr_candidate(agent_editor *ed) {
|
||
if (!ed->cpr_len) return;
|
||
linenoiseEditQueueInput(&ed->edit, ed->cpr_buf, ed->cpr_len);
|
||
ed->cpr_len = 0;
|
||
}
|
||
|
||
static bool agent_tail_ends_with(const char *tail, size_t tail_len,
|
||
const char *seq, size_t seq_len) {
|
||
return tail_len >= seq_len &&
|
||
memcmp(tail + tail_len - seq_len, seq, seq_len) == 0;
|
||
}
|
||
|
||
static bool agent_tail_has_seq_prefix(const char *tail, size_t tail_len,
|
||
const char *seq, size_t seq_len) {
|
||
size_t max = tail_len < seq_len - 1 ? tail_len : seq_len - 1;
|
||
for (size_t n = max; n > 0; n--) {
|
||
if (memcmp(tail + tail_len - n, seq, n) == 0) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Track bracketed paste markers outside linenoise. The nonblocking event loop
|
||
* may receive a paste in chunks; pausing linenoiseEditFeed() until ESC[201~
|
||
* arrives prevents pasted newlines from being interpreted as Enter. */
|
||
static void editor_track_bracketed_paste(agent_editor *ed, char c) {
|
||
static const char start[] = "\x1b[200~";
|
||
static const char end[] = "\x1b[201~";
|
||
|
||
if (ed->paste_tail_len == sizeof(ed->paste_tail)) {
|
||
memmove(ed->paste_tail, ed->paste_tail + 1, sizeof(ed->paste_tail) - 1);
|
||
ed->paste_tail_len--;
|
||
}
|
||
ed->paste_tail[ed->paste_tail_len++] = c;
|
||
|
||
/* The blocking linenoise() path waits inside linenoiseEditPaste() until it
|
||
* sees ESC[201~. In the agent the outer event loop reads stdin in
|
||
* non-blocking chunks; if we let linenoise start parsing ESC[200~ before
|
||
* the closing marker has arrived, pasted newlines can be interpreted as
|
||
* Enter. Keep feeding bytes into linenoise's queue, but don't call
|
||
* linenoiseEditFeed() while the terminal paste envelope is still open. */
|
||
if (agent_tail_ends_with(ed->paste_tail, ed->paste_tail_len,
|
||
start, sizeof(start) - 1))
|
||
{
|
||
ed->paste_open = true;
|
||
ed->paste_start_pending = false;
|
||
} else if (agent_tail_ends_with(ed->paste_tail, ed->paste_tail_len,
|
||
end, sizeof(end) - 1))
|
||
{
|
||
ed->paste_open = false;
|
||
ed->paste_start_pending = false;
|
||
} else {
|
||
ed->paste_start_pending =
|
||
!ed->paste_open &&
|
||
agent_tail_has_seq_prefix(ed->paste_tail, ed->paste_tail_len,
|
||
start, sizeof(start) - 1);
|
||
}
|
||
}
|
||
|
||
/* Separate late CPR replies from real user input before handing bytes to
|
||
* linenoise. */
|
||
static void editor_filter_input_byte(agent_editor *ed, char c) {
|
||
if (ed->cpr_len || (unsigned char)c == 0x1b) {
|
||
if (ed->cpr_len == sizeof(ed->cpr_buf)) {
|
||
editor_flush_cpr_candidate(ed);
|
||
}
|
||
ed->cpr_buf[ed->cpr_len++] = c;
|
||
cpr_state st = cpr_candidate_state(ed->cpr_buf, ed->cpr_len);
|
||
if (st == CPR_COMPLETE) {
|
||
ed->cpr_len = 0; /* Late terminal cursor report: discard it. */
|
||
} else if (st == CPR_INVALID) {
|
||
editor_flush_cpr_candidate(ed);
|
||
}
|
||
return;
|
||
}
|
||
linenoiseEditQueueInput(&ed->edit, &c, 1);
|
||
}
|
||
|
||
/* Queue raw terminal bytes into linenoise while preserving paste envelopes and
|
||
* filtering cursor-position replies. */
|
||
static void editor_queue_bytes(agent_editor *ed, const char *buf, size_t len) {
|
||
for (size_t i = 0; i < len; i++) {
|
||
editor_track_bracketed_paste(ed, buf[i]);
|
||
editor_filter_input_byte(ed, buf[i]);
|
||
}
|
||
}
|
||
|
||
/* Drain stdin in nonblocking mode. The outer event loop decides when queued
|
||
* bytes are fed to linenoiseEditFeed(). */
|
||
static void editor_read_stdin(agent_editor *ed) {
|
||
char buf[256];
|
||
for (;;) {
|
||
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
|
||
if (n > 0) {
|
||
editor_queue_bytes(ed, buf, (size_t)n);
|
||
continue;
|
||
}
|
||
if (n < 0 && errno == EINTR) continue;
|
||
break;
|
||
}
|
||
}
|
||
|
||
static bool editor_take_queued_byte(agent_editor *ed, unsigned char byte) {
|
||
struct linenoiseState *l = &ed->edit;
|
||
for (size_t i = l->queued_input_pos; i < l->queued_input_len; i++) {
|
||
if ((unsigned char)l->queued_input[i] != byte) continue;
|
||
memmove(l->queued_input + i, l->queued_input + i + 1,
|
||
l->queued_input_len - i - 1);
|
||
l->queued_input_len--;
|
||
if (l->queued_input_pos > l->queued_input_len)
|
||
l->queued_input_pos = l->queued_input_len;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool editor_take_bare_escape(agent_editor *ed) {
|
||
if (ed->cpr_len == 1 && (unsigned char)ed->cpr_buf[0] == 0x1b) {
|
||
ed->cpr_len = 0;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static void editor_replace_input(agent_editor *ed, const char *text) {
|
||
if (ed->hidden) editor_show(ed);
|
||
linenoiseEditClear(&ed->edit);
|
||
if (text && text[0]) linenoiseEditInsert(&ed->edit, text, strlen(text));
|
||
}
|
||
|
||
/* Fallback cursor tracking for terminals that do not answer CPR quickly. It is
|
||
* intentionally approximate for wide Unicode; the CPR path handles exact
|
||
* positioning in normal interactive terminals. */
|
||
static void editor_note_output(agent_editor *ed, const char *text, size_t len) {
|
||
int cols = ed->edit.cols > 0 ? (int)ed->edit.cols : 80;
|
||
for (size_t i = 0; i < len; i++) {
|
||
size_t start = i;
|
||
unsigned char c = (unsigned char)text[i];
|
||
if (c == 0x1b && i + 1 < len && text[i + 1] == '[') {
|
||
(void)start;
|
||
i += 2;
|
||
while (i < len) {
|
||
unsigned char e = (unsigned char)text[i];
|
||
if (e >= 0x40 && e <= 0x7e) break;
|
||
i++;
|
||
}
|
||
continue;
|
||
}
|
||
if (c == '\n') {
|
||
ed->output_col = 0;
|
||
ed->output_line_open = false;
|
||
continue;
|
||
}
|
||
if (c == '\r') {
|
||
ed->output_col = 0;
|
||
continue;
|
||
}
|
||
if (c == '\b') {
|
||
if (ed->output_col > 0) ed->output_col--;
|
||
continue;
|
||
}
|
||
|
||
int width = 1;
|
||
if (c == '\t') {
|
||
width = 8 - (ed->output_col & 7);
|
||
} else if (c < 0x20 || c == 0x7f) {
|
||
width = 0;
|
||
} else if (c >= 0xc0) {
|
||
while (i + 1 < len && (((unsigned char)text[i + 1]) & 0xc0) == 0x80)
|
||
i++;
|
||
} else if ((c & 0xc0) == 0x80) {
|
||
width = 0;
|
||
}
|
||
|
||
if (width > 0) {
|
||
ed->output_col = (ed->output_col + width) % cols;
|
||
ed->output_line_open = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Normalize generated LF to CRLF for terminal output without changing the text
|
||
* stored in the transcript. */
|
||
static void editor_write_terminal_text(const char *text, size_t len) {
|
||
size_t start = 0;
|
||
for (size_t i = 0; i < len; i++) {
|
||
if (text[i] != '\n') continue;
|
||
if (i > start) write_all(STDOUT_FILENO, text + start, i - start);
|
||
write_all(STDOUT_FILENO, "\r\n", 2);
|
||
start = i + 1;
|
||
}
|
||
if (start < len) write_all(STDOUT_FILENO, text + start, len - start);
|
||
}
|
||
|
||
/* Locate a CPR reply inside a mixed stdin buffer. Bytes before/after the reply
|
||
* are user input and must be queued back into linenoise. */
|
||
static bool find_cpr_reply(const char *buf, size_t len, size_t *start, size_t *end,
|
||
int *row, int *col) {
|
||
for (size_t i = 0; i + 5 < len; i++) {
|
||
if ((unsigned char)buf[i] != 0x1b || buf[i + 1] != '[') continue;
|
||
size_t p = i + 2;
|
||
int r = 0, c = 0;
|
||
if (p >= len || buf[p] < '0' || buf[p] > '9') continue;
|
||
while (p < len && buf[p] >= '0' && buf[p] <= '9') {
|
||
r = r * 10 + (buf[p++] - '0');
|
||
}
|
||
if (p >= len || buf[p++] != ';') continue;
|
||
if (p >= len || buf[p] < '0' || buf[p] > '9') continue;
|
||
while (p < len && buf[p] >= '0' && buf[p] <= '9') {
|
||
c = c * 10 + (buf[p++] - '0');
|
||
}
|
||
if (p >= len || buf[p] != 'R') continue;
|
||
*start = i;
|
||
*end = p + 1;
|
||
*row = r;
|
||
*col = c;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Ask the terminal for the cursor column after writing model output. Any user
|
||
* bytes read while waiting for the CPR reply are queued back into linenoise so
|
||
* typing during generation is not lost. */
|
||
static bool editor_query_cursor(agent_editor *ed, int *col_out) {
|
||
if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return false;
|
||
|
||
char buf[512];
|
||
size_t len = 0, start = 0, end = 0;
|
||
int row = 0, col = 0;
|
||
write_all(STDOUT_FILENO, "\x1b[6n", 4);
|
||
|
||
for (int attempt = 0; attempt < 8; attempt++) {
|
||
struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN};
|
||
int rc = poll(&pfd, 1, 5);
|
||
if (rc < 0 && errno == EINTR) continue;
|
||
if (rc <= 0) continue;
|
||
for (;;) {
|
||
ssize_t n = read(STDIN_FILENO, buf + len, sizeof(buf) - len);
|
||
if (n > 0) {
|
||
len += (size_t)n;
|
||
if (find_cpr_reply(buf, len, &start, &end, &row, &col)) {
|
||
if (start) editor_queue_bytes(ed, buf, start);
|
||
if (end < len) editor_queue_bytes(ed, buf + end, len - end);
|
||
(void)row;
|
||
*col_out = col;
|
||
return col > 0;
|
||
}
|
||
if (len == sizeof(buf)) break;
|
||
continue;
|
||
}
|
||
if (n < 0 && errno == EINTR) continue;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (len) editor_queue_bytes(ed, buf, len);
|
||
return false;
|
||
}
|
||
|
||
static void editor_move_to_output_cursor(agent_editor *ed) {
|
||
char seq[64];
|
||
write_all(STDOUT_FILENO, "\x1b[1A", 4);
|
||
int n = snprintf(seq, sizeof(seq), "\x1b[%dG", ed->output_col + 1);
|
||
if (n > 0) write_all(STDOUT_FILENO, seq, (size_t)n);
|
||
}
|
||
|
||
static bool editor_get_terminal_size(int *rows, int *cols) {
|
||
struct winsize ws;
|
||
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0) return false;
|
||
if (ws.ws_row < 1 || ws.ws_col < 1) return false;
|
||
*rows = ws.ws_row;
|
||
*cols = ws.ws_col;
|
||
return true;
|
||
}
|
||
|
||
static void editor_csi_cursor(int row, int col) {
|
||
char seq[64];
|
||
int n = snprintf(seq, sizeof(seq), "\x1b[%d;%dH", row, col);
|
||
if (n > 0) write_all(STDOUT_FILENO, seq, (size_t)n);
|
||
}
|
||
|
||
static void editor_save_output_cursor(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
write_all(STDOUT_FILENO, "\0337", 2);
|
||
ed->output_cursor_saved = true;
|
||
}
|
||
|
||
static void editor_restore_output_cursor(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
if (ed->output_cursor_saved) {
|
||
write_all(STDOUT_FILENO, "\0338", 2);
|
||
} else {
|
||
editor_csi_cursor(ed->output_bottom, 1);
|
||
}
|
||
}
|
||
|
||
static void editor_move_to_prompt_row(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
editor_csi_cursor(ed->prompt_row, 1);
|
||
}
|
||
|
||
static void editor_move_to_prompt_cursor(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
if (ed->edit.screen_cursor_row > 0 && ed->edit.screen_cursor_col > 0) {
|
||
editor_csi_cursor(ed->edit.screen_cursor_row, ed->edit.screen_cursor_col);
|
||
} else {
|
||
editor_move_to_prompt_row(ed);
|
||
}
|
||
}
|
||
|
||
static void editor_clear_row(int row) {
|
||
editor_csi_cursor(row, 1);
|
||
write_all(STDOUT_FILENO, "\r\x1b[0K", 5);
|
||
}
|
||
|
||
static void editor_clear_prompt_region(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
for (int row = ed->prompt_row; row <= ed->term_rows; row++)
|
||
editor_clear_row(row);
|
||
|
||
/* In scroll-region mode ds4-agent owns the absolute prompt/status rows.
|
||
* Clearing them directly is more reliable than asking linenoise to clean
|
||
* relative to whatever cursor position the last worker/status transition
|
||
* left behind. Reset linenoise's render bookkeeping so the next show is a
|
||
* pure write into the reserved rows. */
|
||
ed->edit.oldrows = 0;
|
||
ed->edit.oldstatusrows = 0;
|
||
ed->edit.oldrpos = 1;
|
||
ed->edit.oldpos = ed->edit.pos;
|
||
}
|
||
|
||
static void editor_set_scroll_margin(int bottom) {
|
||
char seq[96];
|
||
int n = snprintf(seq, sizeof(seq), "\x1b[1;%dr", bottom);
|
||
if (n > 0) write_all(STDOUT_FILENO, seq, (size_t)n);
|
||
}
|
||
|
||
static void editor_scroll_output_up(int bottom, int lines) {
|
||
if (lines <= 0) return;
|
||
editor_set_scroll_margin(bottom);
|
||
editor_csi_cursor(bottom, 1);
|
||
for (int i = 0; i < lines; i++)
|
||
write_all(STDOUT_FILENO, "\n", 1);
|
||
}
|
||
|
||
static bool editor_set_scroll_layout(agent_editor *ed, int reserved_rows,
|
||
bool allow_shrink,
|
||
bool scroll_on_grow) {
|
||
if (!ed->scroll_region) return false;
|
||
|
||
int rows = 0, cols = 0;
|
||
if (!editor_get_terminal_size(&rows, &cols)) return false;
|
||
if (rows < 8 || cols < 20) return false;
|
||
if (reserved_rows < 2) reserved_rows = 2;
|
||
if (reserved_rows > rows - 2) reserved_rows = rows - 2;
|
||
if (!allow_shrink && ed->reserved_rows > 0 &&
|
||
ed->term_rows == rows && ed->term_cols == cols &&
|
||
reserved_rows < ed->reserved_rows)
|
||
{
|
||
reserved_rows = ed->reserved_rows;
|
||
}
|
||
|
||
int output_bottom = rows - reserved_rows;
|
||
int prompt_row = output_bottom + 1;
|
||
bool changed = ed->term_rows != rows ||
|
||
ed->term_cols != cols ||
|
||
ed->output_bottom != output_bottom ||
|
||
ed->prompt_row != prompt_row ||
|
||
ed->reserved_rows != reserved_rows;
|
||
if (!changed) return true;
|
||
|
||
/* If the prompt grows, rows that were output rows become prompt rows. Do
|
||
* not simply clear them: first scroll the old output region upward by the
|
||
* number of newly reserved rows, exactly as if the model had printed more
|
||
* lines. If the prompt shrinks, no output is restored; the output region
|
||
* simply grows downward and the prompt/status block remains bottom
|
||
* anchored. */
|
||
bool scrolled_output = false;
|
||
if (scroll_on_grow &&
|
||
ed->term_rows == rows && ed->term_cols == cols &&
|
||
ed->output_bottom > 0 && output_bottom < ed->output_bottom)
|
||
{
|
||
editor_scroll_output_up(ed->output_bottom,
|
||
ed->output_bottom - output_bottom);
|
||
scrolled_output = true;
|
||
}
|
||
|
||
editor_set_scroll_margin(output_bottom);
|
||
|
||
ed->term_rows = rows;
|
||
ed->term_cols = cols;
|
||
ed->output_bottom = output_bottom;
|
||
ed->prompt_row = prompt_row;
|
||
ed->reserved_rows = reserved_rows;
|
||
ed->output_cursor_saved = false;
|
||
ed->output_at_scroll_boundary = scrolled_output;
|
||
|
||
for (int row = prompt_row; row <= rows; row++)
|
||
editor_clear_row(row);
|
||
|
||
/* If the prompt grew while generated output was in the middle of a line,
|
||
* the scroll above moved that partial line up with its column intact.
|
||
* Preserve that column when saving the new output cursor; otherwise the
|
||
* next token resumes at column 1 and overwrites the line it was extending. */
|
||
int output_col = ed->output_line_open ? ed->output_col + 1 : 1;
|
||
if (output_col < 1) output_col = 1;
|
||
if (output_col > cols) output_col = cols;
|
||
editor_csi_cursor(output_bottom, output_col);
|
||
editor_save_output_cursor(ed);
|
||
editor_move_to_prompt_row(ed);
|
||
return true;
|
||
}
|
||
|
||
static int editor_linenoise_layout_changed(struct linenoiseState *l,
|
||
size_t prompt_rows,
|
||
size_t status_rows,
|
||
void *privdata) {
|
||
(void)l;
|
||
agent_editor *ed = privdata;
|
||
if (!ed || !ed->scroll_region) return 0;
|
||
if (prompt_rows < 1) prompt_rows = 1;
|
||
int reserved = (int)(prompt_rows + status_rows);
|
||
if (!editor_set_scroll_layout(ed, reserved, true, true)) return 0;
|
||
return ed->prompt_row;
|
||
}
|
||
|
||
/* Keep generated output inside a scroll region that excludes the live prompt
|
||
* and status footer. This lets terminals scroll model/tool output naturally
|
||
* without rewriting the prompt on every streamed token, which is especially
|
||
* important over SSH where full redraws are visibly expensive. */
|
||
static bool editor_configure_scroll_region(agent_editor *ed) {
|
||
if (ed->scroll_region) return true;
|
||
if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return false;
|
||
|
||
int rows = 0, cols = 0;
|
||
if (!editor_get_terminal_size(&rows, &cols)) return false;
|
||
if (rows < 8 || cols < 20) return false;
|
||
|
||
ed->term_rows = 0;
|
||
ed->term_cols = 0;
|
||
ed->output_bottom = 0;
|
||
ed->prompt_row = 0;
|
||
ed->reserved_rows = 0;
|
||
ed->output_cursor_saved = false;
|
||
ed->output_at_scroll_boundary = false;
|
||
ed->scroll_region = true;
|
||
if (!editor_set_scroll_layout(ed, 2, true, false)) return false;
|
||
|
||
/* The agent prints backend startup lines before the editor exists. Once
|
||
* the scroll region is installed, create an append line at the bottom of
|
||
* that region instead of guessing that the old terminal cursor was already
|
||
* there. Without this first scroll, the first agent/model output can
|
||
* overwrite the last visible startup line. */
|
||
editor_scroll_output_up(ed->output_bottom, 1);
|
||
ed->output_cursor_saved = false;
|
||
editor_csi_cursor(ed->output_bottom, 1);
|
||
editor_save_output_cursor(ed);
|
||
editor_move_to_prompt_row(ed);
|
||
return true;
|
||
}
|
||
|
||
static void editor_restore_terminal_layout(agent_editor *ed) {
|
||
if (!ed->scroll_region) return;
|
||
write_all(STDOUT_FILENO, "\x1b[0m", 4);
|
||
write_all(STDOUT_FILENO, "\x1b[r", 3);
|
||
editor_csi_cursor(ed->term_rows, 1);
|
||
write_all(STDOUT_FILENO, "\r\x1b[0K\r\n", 7);
|
||
ed->scroll_region = false;
|
||
ed->output_cursor_saved = false;
|
||
ed->term_rows = ed->term_cols = 0;
|
||
ed->output_bottom = ed->prompt_row = 0;
|
||
ed->reserved_rows = 0;
|
||
ed->output_at_scroll_boundary = false;
|
||
}
|
||
|
||
/* Start linenoise in nonblocking mode and install the status footer. */
|
||
static int editor_start(agent_editor *ed, const char *prompt,
|
||
const char *status, const char *initial) {
|
||
char *input = xmalloc(AGENT_INPUT_INITIAL_BUFLEN);
|
||
snprintf(ed->prompt, sizeof(ed->prompt), "%s", prompt);
|
||
snprintf(ed->status, sizeof(ed->status), "%s", status ? status : "");
|
||
bool had_scroll_region = ed->scroll_region;
|
||
bool use_scroll_region = editor_configure_scroll_region(ed);
|
||
if (use_scroll_region) {
|
||
if (had_scroll_region)
|
||
editor_set_scroll_layout(ed, 2, true, false);
|
||
editor_move_to_prompt_row(ed);
|
||
}
|
||
if (linenoiseEditStart(&ed->edit, STDIN_FILENO, STDOUT_FILENO,
|
||
input, AGENT_INPUT_INITIAL_BUFLEN, ed->prompt) != 0)
|
||
{
|
||
editor_restore_terminal_layout(ed);
|
||
free(input);
|
||
return -1;
|
||
}
|
||
bool embedded_status = agent_footer_is_multiline(ed->status);
|
||
const char *status_start = stdout_is_tty() && !embedded_status ?
|
||
AGENT_STATUS_STYLE_START : "";
|
||
const char *status_end = stdout_is_tty() && ed->status[0] ?
|
||
AGENT_STATUS_STYLE_END : "";
|
||
linenoiseEditSetStatus(&ed->edit, ed->status,
|
||
status_start, status_end);
|
||
linenoiseEditSetLayoutCallback(&ed->edit, editor_linenoise_layout_changed, ed);
|
||
if (isatty(ed->edit.ifd) || getenv("LINENOISE_ASSUME_TTY")) {
|
||
linenoiseHide(&ed->edit);
|
||
linenoiseShow(&ed->edit);
|
||
}
|
||
ed->input = input;
|
||
ed->edit.buflen_max = AGENT_INPUT_MAX_BUFLEN;
|
||
ed->active = true;
|
||
if (set_nonblock(STDIN_FILENO, true, &ed->old_stdin_flags) != 0)
|
||
ed->old_stdin_flags = -1;
|
||
if (initial && initial[0]) linenoiseEditInsert(&ed->edit, initial, strlen(initial));
|
||
ed->hidden = false;
|
||
ed->output_line_open = false;
|
||
ed->prompt_below_output = false;
|
||
ed->output_col = 0;
|
||
ed->cpr_len = 0;
|
||
ed->paste_open = false;
|
||
ed->paste_start_pending = false;
|
||
ed->paste_tail_len = 0;
|
||
return 0;
|
||
}
|
||
|
||
/* Stop the live editor and restore stdin flags. */
|
||
static void editor_stop(agent_editor *ed) {
|
||
if (!ed->active) return;
|
||
/* ds4-agent treats linenoise as a live input widget, not as persistent
|
||
* command scrollback. Clear it before shutdown so submitting a line and
|
||
* immediately reopening the editor does not leave the accepted
|
||
* prompt+input duplicated above the fresh prompt. */
|
||
if (!ed->hidden && (isatty(ed->edit.ifd) || getenv("LINENOISE_ASSUME_TTY")))
|
||
editor_hide(ed);
|
||
linenoiseEditStop(&ed->edit);
|
||
if (ed->old_stdin_flags >= 0) fcntl(STDIN_FILENO, F_SETFL, ed->old_stdin_flags);
|
||
free(ed->edit.buf);
|
||
ed->input = NULL;
|
||
ed->active = false;
|
||
ed->hidden = false;
|
||
ed->output_line_open = false;
|
||
ed->prompt_below_output = false;
|
||
ed->output_col = 0;
|
||
ed->cpr_len = 0;
|
||
ed->paste_open = false;
|
||
ed->paste_start_pending = false;
|
||
ed->paste_tail_len = 0;
|
||
}
|
||
|
||
/* Hide the live prompt before model output is written. In scroll-region mode
|
||
* the output cursor was saved before the prompt was drawn, so restoring it is
|
||
* enough to append more model/tool bytes without touching the prompt rows. */
|
||
static void editor_hide(agent_editor *ed) {
|
||
if (!ed->active || ed->hidden) return;
|
||
if (ed->scroll_region) {
|
||
editor_clear_prompt_region(ed);
|
||
editor_restore_output_cursor(ed);
|
||
ed->hidden = true;
|
||
return;
|
||
}
|
||
linenoiseHide(&ed->edit);
|
||
if (ed->prompt_below_output) {
|
||
editor_move_to_output_cursor(ed);
|
||
ed->prompt_below_output = false;
|
||
}
|
||
ed->hidden = true;
|
||
}
|
||
|
||
/* Restore the live prompt after output. The primary path draws it in the
|
||
* reserved bottom rows; the fallback path keeps the older one-row-below-output
|
||
* trick for terminals where scroll regions are unavailable. */
|
||
static void editor_show(agent_editor *ed) {
|
||
if (!ed->active || !ed->hidden) return;
|
||
if (ed->scroll_region) {
|
||
editor_save_output_cursor(ed);
|
||
editor_move_to_prompt_row(ed);
|
||
write_all(STDOUT_FILENO, "\x1b[0m", 4);
|
||
linenoiseShow(&ed->edit);
|
||
ed->hidden = false;
|
||
return;
|
||
}
|
||
if (ed->output_line_open) {
|
||
write_all(STDOUT_FILENO, "\r\n", 2);
|
||
ed->prompt_below_output = true;
|
||
} else {
|
||
ed->prompt_below_output = false;
|
||
}
|
||
/* Model/tool output can leave SGR attributes active while it streams.
|
||
* Redrawing linenoise always starts from normal attributes; tool rendering
|
||
* re-emits its own color on the next streamed byte if it is still inside a
|
||
* colored parameter. */
|
||
write_all(STDOUT_FILENO, "\x1b[0m", 4);
|
||
linenoiseShow(&ed->edit);
|
||
ed->hidden = false;
|
||
}
|
||
|
||
static void editor_update_prompt(agent_editor *ed, const char *prompt) {
|
||
snprintf(ed->prompt, sizeof(ed->prompt), "%s", prompt);
|
||
ed->edit.prompt = ed->prompt;
|
||
ed->edit.plen = strlen(ed->prompt);
|
||
}
|
||
|
||
static void editor_update_status(agent_editor *ed, const char *status) {
|
||
snprintf(ed->status, sizeof(ed->status), "%s", status ? status : "");
|
||
bool embedded_status = agent_footer_is_multiline(ed->status);
|
||
const char *status_start = stdout_is_tty() && !embedded_status ?
|
||
AGENT_STATUS_STYLE_START : "";
|
||
const char *status_end = stdout_is_tty() && ed->status[0] ?
|
||
AGENT_STATUS_STYLE_END : "";
|
||
linenoiseEditSetStatus(&ed->edit, ed->status,
|
||
status_start, status_end);
|
||
}
|
||
|
||
static void editor_set_prompt_status(agent_editor *ed, const char *prompt,
|
||
const char *status) {
|
||
bool prompt_changed = strcmp(ed->prompt, prompt) != 0;
|
||
bool status_changed = strcmp(ed->status, status ? status : "") != 0;
|
||
if (!ed->active || (!prompt_changed && !status_changed)) return;
|
||
if (ed->hidden) {
|
||
if (prompt_changed) editor_update_prompt(ed, prompt);
|
||
if (status_changed) editor_update_status(ed, status);
|
||
return;
|
||
}
|
||
editor_hide(ed);
|
||
if (prompt_changed) editor_update_prompt(ed, prompt);
|
||
if (status_changed) editor_update_status(ed, status);
|
||
editor_show(ed);
|
||
}
|
||
|
||
static void editor_redraw_visible_prompt(agent_editor *ed) {
|
||
if (!ed->active || !ed->scroll_region) return;
|
||
editor_clear_prompt_region(ed);
|
||
editor_move_to_prompt_row(ed);
|
||
write_all(STDOUT_FILENO, "\x1b[0m", 4);
|
||
linenoiseShow(&ed->edit);
|
||
ed->last_prompt_redraw_time = now_sec();
|
||
}
|
||
|
||
static bool editor_prompt_redraw_due(agent_editor *ed) {
|
||
double now = now_sec();
|
||
if (ed->last_prompt_redraw_time <= 0.0 ||
|
||
now - ed->last_prompt_redraw_time >= AGENT_STATUS_REDRAW_INTERVAL_SEC)
|
||
{
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static void editor_write_scroll_output_preserve_prompt(agent_editor *ed,
|
||
const char *text,
|
||
size_t len) {
|
||
static const char sync_start[] = "\x1b[?2026h";
|
||
static const char sync_end[] = "\x1b[?2026l";
|
||
if (!len) return;
|
||
|
||
write_all(STDOUT_FILENO, sync_start, sizeof(sync_start) - 1);
|
||
editor_restore_output_cursor(ed);
|
||
editor_write_terminal_text(text, len);
|
||
editor_note_output(ed, text, len);
|
||
editor_save_output_cursor(ed);
|
||
write_all(STDOUT_FILENO, "\x1b[0m", 4);
|
||
editor_move_to_prompt_cursor(ed);
|
||
write_all(STDOUT_FILENO, sync_end, sizeof(sync_end) - 1);
|
||
ed->output_at_scroll_boundary = true;
|
||
}
|
||
|
||
/* Serialize async model/tool output with linenoise. This is the central
|
||
* terminal contract. In scroll-region mode the live prompt stays painted:
|
||
* output is appended in the upper scroll area, then the cursor is returned to
|
||
* linenoise's remembered prompt position. The fallback path still hides and
|
||
* redraws because it has no protected prompt rows. */
|
||
static void editor_write_async(agent_editor *ed, const char *text, size_t len,
|
||
const char *prompt, const char *status,
|
||
bool force_show) {
|
||
if (ed->scroll_region && ed->active && !ed->hidden && len) {
|
||
bool prompt_changed = strcmp(ed->prompt, prompt) != 0;
|
||
bool status_changed = strcmp(ed->status, status ? status : "") != 0;
|
||
|
||
editor_write_scroll_output_preserve_prompt(ed, text, len);
|
||
if (prompt_changed) editor_update_prompt(ed, prompt);
|
||
if (status_changed) editor_update_status(ed, status);
|
||
if ((force_show || editor_prompt_redraw_due(ed)) &&
|
||
(prompt_changed || status_changed))
|
||
{
|
||
editor_redraw_visible_prompt(ed);
|
||
}
|
||
return;
|
||
}
|
||
|
||
editor_hide(ed);
|
||
if (len) {
|
||
editor_write_terminal_text(text, len);
|
||
if (ed->scroll_region) ed->output_at_scroll_boundary = true;
|
||
if (!ed->scroll_region) {
|
||
if (text[len - 1] == '\n' || text[len - 1] == '\r') {
|
||
ed->output_col = 0;
|
||
ed->output_line_open = false;
|
||
} else {
|
||
int col = 0;
|
||
if (editor_query_cursor(ed, &col)) {
|
||
int cols = ed->edit.cols > 0 ? (int)ed->edit.cols : 80;
|
||
ed->output_col = col > 0 ? col - 1 : 0;
|
||
ed->output_line_open = true;
|
||
if (ed->output_col + 1 >= cols) {
|
||
write_all(STDOUT_FILENO, "\r\n", 2);
|
||
ed->output_col = 0;
|
||
}
|
||
} else {
|
||
editor_note_output(ed, text, len);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (ed->active) {
|
||
editor_update_prompt(ed, prompt);
|
||
editor_update_status(ed, status);
|
||
/* In scroll-region mode this saves the current output cursor and
|
||
* redraws linenoise in the fixed prompt rows. In fallback mode it may
|
||
* put the prompt below an unfinished generated line. */
|
||
if (force_show || len) editor_show(ed);
|
||
}
|
||
}
|
||
|
||
/* Ctrl+C while idle is an edit-cancel key, not an exit key. Clear the real
|
||
* linenoise buffer so stale text cannot be submitted later, then leave a short
|
||
* visible hint about the explicit EOF exit path. */
|
||
static void editor_cancel_input_with_hint(agent_editor *ed,
|
||
const char *prompt,
|
||
const char *status) {
|
||
if (!ed->active) return;
|
||
if (ed->hidden) editor_show(ed);
|
||
linenoiseEditClear(&ed->edit);
|
||
const char *msg = stdout_is_tty() ?
|
||
"\x1b[1;33mpress Ctrl+D to exit\x1b[0m\n" :
|
||
"press Ctrl+D to exit\n";
|
||
editor_write_async(ed, msg, strlen(msg), prompt, status, true);
|
||
}
|
||
|
||
static void runtime_help(void) {
|
||
puts("Commands:");
|
||
puts(" /help Show this help.");
|
||
puts(" /save Save the current session.");
|
||
puts(" /compact Compact the current session context now.");
|
||
puts(" /list List saved sessions.");
|
||
puts(" /switch SHA Load a saved session and show recent history.");
|
||
puts(" /del SHA Delete a saved session.");
|
||
puts(" /strip SHA Strip KV payload; /switch rebuilds it by prefill.");
|
||
puts(" /history [N] Show N recent user turns from the current session.");
|
||
puts(" /power N Set GPU duty cycle percentage, 1..100.");
|
||
puts(" /new Start a fresh session from the system prompt.");
|
||
puts(" /quit, /exit Exit.");
|
||
puts(" Ctrl+C Interrupt generation; clear edited text.");
|
||
puts(" Enter Queue text while the agent is busy.");
|
||
puts(" Ctrl+X Edit the first queued prompt.");
|
||
puts(" ESC Interrupt and send queued prompt immediately.");
|
||
puts(" Ctrl+D Exit from an empty prompt.");
|
||
}
|
||
|
||
static void agent_format_ctx_size(int ctx_size, char *buf, size_t len) {
|
||
if (ctx_size >= 1000) {
|
||
if (ctx_size % 1000 == 0) snprintf(buf, len, "%dk", ctx_size / 1000);
|
||
else snprintf(buf, len, "%.1fk", (double)ctx_size / 1000.0);
|
||
} else {
|
||
snprintf(buf, len, "%d", ctx_size);
|
||
}
|
||
}
|
||
|
||
static void agent_format_welcome_banner(const agent_config *cfg,
|
||
char *buf, size_t len) {
|
||
char ctx[32];
|
||
agent_format_ctx_size(cfg->gen.ctx_size, ctx, sizeof(ctx));
|
||
if (stdout_is_tty()) {
|
||
snprintf(buf, len,
|
||
"\x1b[1;97mDwarf\x1b[1;94mStar\x1b[0m 🐋 Agent, context %s tokens\n\n",
|
||
ctx);
|
||
} else {
|
||
snprintf(buf, len, "DwarfStar Agent, context %s tokens\n\n", ctx);
|
||
}
|
||
}
|
||
|
||
static void editor_write_welcome_banner(agent_editor *editor,
|
||
const agent_config *cfg,
|
||
const char *prompt,
|
||
const char *statusline) {
|
||
char banner[256];
|
||
agent_format_welcome_banner(cfg, banner, sizeof(banner));
|
||
editor_write_async(editor, banner, strlen(banner), prompt, statusline, true);
|
||
}
|
||
|
||
/* Initialize the worker, cache directory, sysprompt checkpoint path, trace file,
|
||
* and model thread. After this returns, all DS4 session mutation happens on
|
||
* the worker thread. */
|
||
static int agent_worker_init(agent_worker *w, ds4_engine *engine, agent_config *cfg) {
|
||
memset(w, 0, sizeof(*w));
|
||
w->engine = engine;
|
||
w->cfg = cfg;
|
||
w->wake_fd[0] = -1;
|
||
w->wake_fd[1] = -1;
|
||
pthread_mutex_init(&w->mu, NULL);
|
||
pthread_cond_init(&w->cond, NULL);
|
||
w->status.state = AGENT_WORKER_IDLE;
|
||
if (pipe(w->wake_fd) != 0) return -1;
|
||
int old_flags;
|
||
set_nonblock(w->wake_fd[0], true, &old_flags);
|
||
set_nonblock(w->wake_fd[1], true, &old_flags);
|
||
if (ds4_session_create(&w->session, engine, cfg->gen.ctx_size) != 0) {
|
||
fprintf(stderr, "ds4-agent: session backend is required\n");
|
||
return -1;
|
||
}
|
||
w->cache_dir = agent_default_cache_dir();
|
||
if (!agent_mkdir_p(w->cache_dir)) {
|
||
fprintf(stderr, "ds4-agent: failed to create %s: %s\n",
|
||
w->cache_dir, strerror(errno));
|
||
return -1;
|
||
}
|
||
ds4_web_config web_cfg = {
|
||
.home_dir = getenv("HOME"),
|
||
.port = 9333,
|
||
.confirm = agent_web_confirm,
|
||
.confirm_privdata = w,
|
||
.log = agent_web_log,
|
||
.log_privdata = w,
|
||
.cancel = agent_web_cancel,
|
||
.cancel_privdata = w,
|
||
};
|
||
w->web = ds4_web_create(&web_cfg);
|
||
w->sysprompt_path = ds4_kvstore_path_join(w->cache_dir, "sysprompt.kv");
|
||
if (cfg->gen.trace_path && cfg->gen.trace_path[0]) {
|
||
w->trace = fopen(cfg->gen.trace_path, "ab");
|
||
if (!w->trace) {
|
||
fprintf(stderr, "ds4-agent: failed to open trace %s: %s\n",
|
||
cfg->gen.trace_path, strerror(errno));
|
||
return -1;
|
||
}
|
||
}
|
||
if (pthread_create(&w->thread, NULL, worker_main, w) != 0) return -1;
|
||
return 0;
|
||
}
|
||
|
||
/* Shut down the worker and release owned resources, including any live bash
|
||
* process groups. */
|
||
static void agent_worker_free(agent_worker *w) {
|
||
worker_stop(w);
|
||
if (w->thread) pthread_join(w->thread, NULL);
|
||
agent_bash_jobs_free(w);
|
||
ds4_web_free(w->web);
|
||
ds4_session_free(w->session);
|
||
ds4_tokens_free(&w->transcript);
|
||
free(w->cache_dir);
|
||
free(w->sysprompt_path);
|
||
free(w->session_title);
|
||
free(w->legacy_session_path_to_delete);
|
||
free(w->queued_user_drain_text);
|
||
if (w->wake_fd[0] >= 0) close(w->wake_fd[0]);
|
||
if (w->wake_fd[1] >= 0) close(w->wake_fd[1]);
|
||
if (w->trace) fclose(w->trace);
|
||
free(w->cmd_text);
|
||
free(w->out);
|
||
pthread_cond_destroy(&w->cond);
|
||
pthread_mutex_destroy(&w->mu);
|
||
}
|
||
|
||
typedef enum {
|
||
AGENT_YES_NO_AUTO_NONE,
|
||
AGENT_YES_NO_AUTO_NO,
|
||
AGENT_YES_NO_AUTO_YES,
|
||
} agent_yes_no_auto;
|
||
|
||
typedef struct {
|
||
int timeout_sec;
|
||
agent_yes_no_auto timeout_answer;
|
||
} agent_yes_no_options;
|
||
|
||
static const char *agent_yes_no_auto_name(agent_yes_no_auto answer) {
|
||
switch (answer) {
|
||
case AGENT_YES_NO_AUTO_NO: return "no";
|
||
case AGENT_YES_NO_AUTO_YES: return "yes";
|
||
default: return "";
|
||
}
|
||
}
|
||
|
||
/* Shared y/n prompt. By default it blocks forever like the historical helper;
|
||
* callers that cannot safely stall the agent can request an automatic answer
|
||
* after timeout_sec seconds. */
|
||
static bool agent_prompt_yes_no_ex(const char *prompt,
|
||
const agent_yes_no_options *opts,
|
||
bool *timed_out) {
|
||
char buf[32];
|
||
int timeout_sec = opts ? opts->timeout_sec : 0;
|
||
agent_yes_no_auto auto_answer = opts ?
|
||
opts->timeout_answer : AGENT_YES_NO_AUTO_NONE;
|
||
bool use_timeout = timeout_sec > 0 && auto_answer != AGENT_YES_NO_AUTO_NONE;
|
||
double deadline = use_timeout ? now_sec() + timeout_sec : 0.0;
|
||
|
||
if (timed_out) *timed_out = false;
|
||
for (;;) {
|
||
printf("%s", prompt);
|
||
if (use_timeout) {
|
||
int rem = (int)(deadline - now_sec() + 0.999);
|
||
if (rem < 0) rem = 0;
|
||
printf("[auto-%s in %ds] ", agent_yes_no_auto_name(auto_answer), rem);
|
||
}
|
||
fflush(stdout);
|
||
if (use_timeout) {
|
||
double rem_sec = deadline - now_sec();
|
||
if (rem_sec <= 0.0) {
|
||
if (timed_out) *timed_out = true;
|
||
printf("\n");
|
||
return auto_answer == AGENT_YES_NO_AUTO_YES;
|
||
}
|
||
struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN};
|
||
int timeout_ms = (int)(rem_sec * 1000.0) + 1;
|
||
int rc;
|
||
do {
|
||
rc = poll(&pfd, 1, timeout_ms);
|
||
} while (rc < 0 && errno == EINTR);
|
||
if (rc == 0) {
|
||
if (timed_out) *timed_out = true;
|
||
printf("\n");
|
||
return auto_answer == AGENT_YES_NO_AUTO_YES;
|
||
}
|
||
if (rc < 0) return false;
|
||
}
|
||
/* stdin may be in non-blocking mode (set by editor_start).
|
||
* Temporarily switch to blocking so fgets can wait for input. */
|
||
int saved_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
|
||
if (saved_flags >= 0 && (saved_flags & O_NONBLOCK)) {
|
||
fcntl(STDIN_FILENO, F_SETFL, saved_flags & ~O_NONBLOCK);
|
||
}
|
||
bool got_line = fgets(buf, sizeof(buf), stdin) != NULL;
|
||
if (saved_flags >= 0 && (saved_flags & O_NONBLOCK)) {
|
||
fcntl(STDIN_FILENO, F_SETFL, saved_flags);
|
||
}
|
||
if (!got_line) return false;
|
||
char *p = buf;
|
||
while (*p == ' ' || *p == '\t') p++;
|
||
if (*p == 'y' || *p == 'Y') return true;
|
||
if (*p == 'n' || *p == 'N') return false;
|
||
}
|
||
}
|
||
|
||
static bool agent_prompt_yes_no(const char *prompt) {
|
||
return agent_prompt_yes_no_ex(prompt, NULL, NULL);
|
||
}
|
||
|
||
/* Ask before discarding a dirty user session. Fresh sessions that contain only
|
||
* the system prompt are deliberately ignored. */
|
||
static bool agent_maybe_save_before_leaving_session(agent_worker *w) {
|
||
if (!agent_worker_needs_save(w)) return true;
|
||
if (!agent_prompt_yes_no("Save current session? (y/n) ")) return true;
|
||
char err[160] = {0};
|
||
if (agent_worker_save_session(w, err, sizeof(err))) return true;
|
||
printf("save failed: %s\n", err);
|
||
return agent_prompt_yes_no("Continue anyway? (y/n) ");
|
||
}
|
||
|
||
typedef enum {
|
||
AGENT_EXIT_CANCEL,
|
||
AGENT_EXIT_CLEAN,
|
||
AGENT_EXIT_NOW,
|
||
} agent_exit_save_result;
|
||
|
||
/* Process exit is different from /new or /switch: once the terminal is already
|
||
* restored, declining the save can terminate immediately and let the OS reclaim
|
||
* model/Metal resources instead of waiting for orderly teardown. */
|
||
static agent_exit_save_result agent_maybe_save_before_exiting(agent_worker *w) {
|
||
if (!agent_worker_needs_save(w)) return AGENT_EXIT_CLEAN;
|
||
if (!agent_prompt_yes_no("Save current session? (y/n) ")) return AGENT_EXIT_NOW;
|
||
char err[160] = {0};
|
||
if (agent_worker_save_session(w, err, sizeof(err))) return AGENT_EXIT_CLEAN;
|
||
printf("save failed: %s\n", err);
|
||
return agent_prompt_yes_no("Continue anyway? (y/n) ") ?
|
||
AGENT_EXIT_NOW : AGENT_EXIT_CANCEL;
|
||
}
|
||
|
||
/* ============================================================================
|
||
* Interactive Runtime Loop
|
||
* ============================================================================
|
||
*/
|
||
|
||
static void agent_noninteractive_marker(const char *msg) {
|
||
write_all(STDERR_FILENO, msg, strlen(msg));
|
||
write_all(STDERR_FILENO, "\n", 1);
|
||
}
|
||
|
||
static int agent_read_stdin_available(agent_input_buf *in, bool *eof) {
|
||
char buf[4096];
|
||
for (;;) {
|
||
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
|
||
if (n > 0) {
|
||
agent_input_buf_append(in, buf, (size_t)n);
|
||
continue;
|
||
}
|
||
if (n == 0) {
|
||
*eof = true;
|
||
return 0;
|
||
}
|
||
if (errno == EINTR) continue;
|
||
if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
|
||
perror("ds4-agent: read stdin");
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
/* Headless mode is intentionally just another front-end for the same worker.
|
||
* With -p/--prompt it is a one-shot execution. Without -p it becomes a small
|
||
* stdin protocol: announce readiness on stderr, collect bytes until stdin has
|
||
* been quiet for 200 ms, submit that buffer as one prompt, and keep reading so
|
||
* later input can be queued while the model is still working. */
|
||
static int run_agent_non_interactive(ds4_engine *engine, agent_config *cfg) {
|
||
agent_worker worker;
|
||
if (agent_worker_init(&worker, engine, cfg) != 0) return 1;
|
||
|
||
const bool one_shot = cfg->gen.prompt != NULL;
|
||
bool one_shot_submitted = false;
|
||
bool stdin_eof = false;
|
||
bool waiting_announced = false;
|
||
bool stdin_nonblock = false;
|
||
int old_stdin_flags = 0;
|
||
agent_input_buf input = {0};
|
||
agent_prompt_queue queue = {0};
|
||
double quiet_deadline = 0.0;
|
||
int rc = 0;
|
||
|
||
if (!one_shot) {
|
||
if (set_nonblock(STDIN_FILENO, true, &old_stdin_flags) != 0) {
|
||
perror("ds4-agent: nonblocking stdin");
|
||
agent_worker_free(&worker);
|
||
return 1;
|
||
}
|
||
stdin_nonblock = true;
|
||
}
|
||
|
||
while (true) {
|
||
bool initialized = worker_is_initialized(&worker, NULL);
|
||
bool idle = worker_is_idle(&worker);
|
||
|
||
if (one_shot && !one_shot_submitted && initialized) {
|
||
if (worker_submit(&worker, cfg->gen.prompt))
|
||
one_shot_submitted = true;
|
||
idle = false;
|
||
}
|
||
|
||
if (!one_shot && queue.len && idle) {
|
||
char *queued = agent_prompt_queue_take_all(&queue);
|
||
if (worker_submit(&worker, queued)) {
|
||
idle = false;
|
||
} else {
|
||
agent_prompt_queue_push_front(&queue, queued);
|
||
queued = NULL;
|
||
}
|
||
free(queued);
|
||
}
|
||
|
||
if (!one_shot && initialized && idle && !queue.len &&
|
||
input.len == 0 && !stdin_eof && !waiting_announced)
|
||
{
|
||
agent_noninteractive_marker("+DWARFSTAR_WAITING");
|
||
waiting_announced = true;
|
||
}
|
||
|
||
int timeout_ms = -1;
|
||
if (!one_shot && input.len > 0) {
|
||
double rem = quiet_deadline - now_sec();
|
||
timeout_ms = rem <= 0.0 ? 0 : (int)(rem * 1000.0) + 1;
|
||
}
|
||
|
||
struct pollfd pfd[2];
|
||
int nfds = 0;
|
||
int wake_idx = nfds;
|
||
pfd[nfds++] = (struct pollfd){.fd = worker.wake_fd[0], .events = POLLIN};
|
||
int stdin_idx = -1;
|
||
if (!one_shot && initialized && !stdin_eof) {
|
||
stdin_idx = nfds;
|
||
pfd[nfds++] = (struct pollfd){.fd = STDIN_FILENO, .events = POLLIN};
|
||
}
|
||
|
||
int prc = poll(pfd, (nfds_t)nfds, timeout_ms);
|
||
if (prc < 0) {
|
||
if (errno == EINTR) continue;
|
||
perror("ds4-agent: poll");
|
||
rc = 1;
|
||
break;
|
||
}
|
||
if (pfd[wake_idx].revents & POLLIN) drain_wake_fd(worker.wake_fd[0]);
|
||
if (stdin_idx >= 0 && (pfd[stdin_idx].revents & (POLLIN | POLLHUP))) {
|
||
size_t old_len = input.len;
|
||
if (agent_read_stdin_available(&input, &stdin_eof) != 0) {
|
||
rc = 1;
|
||
break;
|
||
}
|
||
if (input.len != old_len) {
|
||
quiet_deadline = now_sec() + 0.200;
|
||
waiting_announced = false;
|
||
}
|
||
}
|
||
|
||
char *out = NULL;
|
||
size_t out_len = 0;
|
||
agent_status st = {0};
|
||
worker_consume(&worker, &out, &out_len, &st);
|
||
if (out && out_len) {
|
||
write_all(STDOUT_FILENO, out, out_len);
|
||
fflush(stdout);
|
||
}
|
||
free(out);
|
||
|
||
if (worker_take_queued_user_drain_request(&worker)) {
|
||
char *queued = agent_prompt_queue_take_all(&queue);
|
||
worker_answer_queued_user_drain(&worker, queued);
|
||
}
|
||
|
||
if (st.state == AGENT_WORKER_ERROR) {
|
||
fprintf(stderr, "ds4-agent: %s\n",
|
||
st.error[0] ? st.error : "worker error");
|
||
rc = 1;
|
||
break;
|
||
}
|
||
|
||
if (!one_shot && input.len > 0 &&
|
||
(stdin_eof || now_sec() >= quiet_deadline))
|
||
{
|
||
char *prompt = agent_input_buf_take(&input);
|
||
if (worker_is_idle(&worker) && queue.len == 0) {
|
||
if (!worker_submit(&worker, prompt)) {
|
||
agent_prompt_queue_push(&queue, prompt);
|
||
agent_noninteractive_marker("+DWARFSTAR_QUEUED");
|
||
}
|
||
} else {
|
||
agent_prompt_queue_push(&queue, prompt);
|
||
agent_noninteractive_marker("+DWARFSTAR_QUEUED");
|
||
}
|
||
free(prompt);
|
||
waiting_announced = false;
|
||
}
|
||
|
||
if (one_shot && one_shot_submitted && worker_is_idle(&worker)) break;
|
||
if (!one_shot && stdin_eof && input.len == 0 &&
|
||
queue.len == 0 && worker_is_idle(&worker))
|
||
break;
|
||
}
|
||
|
||
/* Drain anything published between the final status transition and the
|
||
* loop exit. This keeps stdout complete without adding another protocol. */
|
||
char *out = NULL;
|
||
size_t out_len = 0;
|
||
worker_consume(&worker, &out, &out_len, NULL);
|
||
if (out && out_len) {
|
||
write_all(STDOUT_FILENO, out, out_len);
|
||
fflush(stdout);
|
||
}
|
||
free(out);
|
||
|
||
if (stdin_nonblock) fcntl(STDIN_FILENO, F_SETFL, old_stdin_flags);
|
||
agent_input_buf_free(&input);
|
||
agent_prompt_queue_free(&queue);
|
||
agent_worker_free(&worker);
|
||
return rc;
|
||
}
|
||
|
||
/* Main UI loop. poll() multiplexes stdin with the worker wake pipe; all
|
||
* terminal writes go through editor_write_async() so linenoise, status footer,
|
||
* model output, and tool output never race each other. */
|
||
static int run_agent(ds4_engine *engine, agent_config *cfg) {
|
||
agent_worker worker;
|
||
if (agent_worker_init(&worker, engine, cfg) != 0) return 1;
|
||
|
||
char hist[PATH_MAX];
|
||
const char *home = getenv("HOME");
|
||
if (!home || !home[0]) home = ".";
|
||
snprintf(hist, sizeof(hist), "%s/.ds4_agent_history", home);
|
||
/* The agent uses ANSI scroll regions when possible: model/tool output
|
||
* scrolls above the live linenoise prompt and status footer, so streaming
|
||
* tokens do not require repainting the bottom rows. Terminals without
|
||
* scroll-region support fall back to the older prompt-below-output path. */
|
||
linenoiseSetMultiLine(1);
|
||
linenoiseHistorySetMaxLen(512);
|
||
linenoiseHistoryLoad(hist);
|
||
agent_completion_worker = &worker;
|
||
linenoiseSetCompletionCallback(agent_switch_completion_callback);
|
||
|
||
agent_status st;
|
||
worker_get_status(&worker, &st);
|
||
char prompt[160];
|
||
char statusline[4096];
|
||
build_prompt_text(&st, prompt, sizeof(prompt));
|
||
build_footer_text(&st, NULL, 80, statusline, sizeof(statusline));
|
||
|
||
agent_editor editor = {0};
|
||
agent_prompt_queue queue = {0};
|
||
if (editor_start(&editor, prompt, statusline, NULL) != 0) {
|
||
fprintf(stderr, "ds4-agent: failed to start line editor\n");
|
||
agent_worker_free(&worker);
|
||
return 1;
|
||
}
|
||
editor_write_welcome_banner(&editor, cfg, prompt, statusline);
|
||
|
||
char *initial_pending = cfg->gen.prompt && cfg->gen.prompt[0] ?
|
||
xstrdup(cfg->gen.prompt) : NULL;
|
||
|
||
bool running = true;
|
||
bool exit_save_handled = false;
|
||
bool show_welcome_after_restart = false;
|
||
bool force_status_redraw_after_restart = false;
|
||
char *restore_line = NULL;
|
||
while (running) {
|
||
/* If a bash child process changed the terminal mode (e.g., from raw
|
||
* to cooked), restore raw mode so linenoise continues to work. */
|
||
if (worker_check_raw_mode_restore(&worker)) {
|
||
linenoiseRestoreRawMode();
|
||
}
|
||
struct pollfd pfd[2] = {
|
||
{.fd = STDIN_FILENO, .events = POLLIN},
|
||
{.fd = worker.wake_fd[0], .events = POLLIN},
|
||
};
|
||
int timeout = (!editor.paste_open && !editor.paste_start_pending &&
|
||
linenoiseEditQueuedInput(&editor.edit) > 0) ? 0 : 100;
|
||
int rc = poll(pfd, 2, timeout);
|
||
if (rc < 0 && errno != EINTR) break;
|
||
|
||
if (agent_sigint) {
|
||
agent_sigint = 0;
|
||
if (worker_is_idle(&worker)) {
|
||
editor_cancel_input_with_hint(&editor, prompt, statusline);
|
||
} else {
|
||
worker_interrupt(&worker);
|
||
}
|
||
}
|
||
|
||
if (rc > 0 && (pfd[0].revents & POLLIN)) editor_read_stdin(&editor);
|
||
|
||
/* Linenoise runs the terminal in raw mode, so Ctrl+C normally arrives
|
||
* as byte 3 instead of SIGINT. Handle it before worker output is
|
||
* drained and repainted; otherwise a busy decoding stream can leave the
|
||
* interrupt waiting behind a large terminal-output backlog. */
|
||
if (editor_take_queued_byte(&editor, 3)) { /* Ctrl+C */
|
||
if (!worker_is_idle(&worker)) {
|
||
worker_interrupt(&worker);
|
||
} else {
|
||
editor_cancel_input_with_hint(&editor, prompt, statusline);
|
||
}
|
||
}
|
||
|
||
if (rc > 0 && (pfd[1].revents & POLLIN)) drain_wake_fd(worker.wake_fd[0]);
|
||
|
||
char *out = NULL;
|
||
size_t out_len = 0;
|
||
worker_consume(&worker, &out, &out_len, &st);
|
||
build_prompt_text(&st, prompt, sizeof(prompt));
|
||
int footer_cols = editor.edit.cols > 0 ? (int)editor.edit.cols : 80;
|
||
build_footer_text(&st, &queue, footer_cols, statusline, sizeof(statusline));
|
||
if (out && out_len) {
|
||
bool force_show = st.state == AGENT_WORKER_IDLE ||
|
||
st.state == AGENT_WORKER_ERROR ||
|
||
st.state == AGENT_WORKER_STOPPED;
|
||
editor_write_async(&editor, out, out_len, prompt, statusline, force_show);
|
||
} else {
|
||
editor_set_prompt_status(&editor, prompt, statusline);
|
||
if (editor.hidden && (st.state == AGENT_WORKER_IDLE ||
|
||
st.state == AGENT_WORKER_ERROR ||
|
||
st.state == AGENT_WORKER_STOPPED))
|
||
editor_show(&editor);
|
||
}
|
||
if (st.state == AGENT_WORKER_ERROR && st.error[0]) {
|
||
char msg[320];
|
||
int n = snprintf(msg, sizeof(msg), "\nds4-agent: %s\n", st.error);
|
||
editor_write_async(&editor, msg, n > 0 ? (size_t)n : 0,
|
||
prompt, statusline, true);
|
||
pthread_mutex_lock(&worker.mu);
|
||
worker.status.state = AGENT_WORKER_IDLE;
|
||
worker.status.prefill_tps = 0.0;
|
||
worker.status.greedy_sampling = false;
|
||
worker.status.error[0] = '\0';
|
||
pthread_mutex_unlock(&worker.mu);
|
||
}
|
||
free(out);
|
||
|
||
if (worker_take_queued_user_drain_request(&worker)) {
|
||
char *echo = agent_prompt_queue_take_all_echo(&queue);
|
||
char *queued = agent_prompt_queue_take_all(&queue);
|
||
if (echo) {
|
||
build_footer_text(&st, &queue, footer_cols, statusline, sizeof(statusline));
|
||
editor_write_async(&editor, echo, strlen(echo), prompt, statusline, true);
|
||
free(echo);
|
||
}
|
||
worker_answer_queued_user_drain(&worker, queued);
|
||
continue;
|
||
}
|
||
|
||
char web_approval_msg[256];
|
||
if (worker_take_web_approval_request(&worker, web_approval_msg,
|
||
sizeof(web_approval_msg)))
|
||
{
|
||
char *saved_input = NULL;
|
||
if (editor.active && editor.edit.buf && editor.edit.len)
|
||
saved_input = xstrndup(editor.edit.buf, editor.edit.len);
|
||
editor_stop(&editor);
|
||
editor_restore_terminal_layout(&editor);
|
||
agent_yes_no_options approval_opts = {
|
||
.timeout_sec = 30,
|
||
.timeout_answer = AGENT_YES_NO_AUTO_NO,
|
||
};
|
||
bool approval_timed_out = false;
|
||
bool allow = agent_prompt_yes_no_ex(web_approval_msg,
|
||
&approval_opts,
|
||
&approval_timed_out);
|
||
worker_answer_web_approval(&worker, allow,
|
||
approval_timed_out ? "Chrome browser start approval timed out" : NULL);
|
||
worker_get_status(&worker, &st);
|
||
build_prompt_text(&st, prompt, sizeof(prompt));
|
||
int restart_cols = editor.edit.cols > 0 ? (int)editor.edit.cols : 80;
|
||
build_footer_text(&st, &queue, restart_cols, statusline, sizeof(statusline));
|
||
editor_start(&editor, prompt, statusline, saved_input);
|
||
free(saved_input);
|
||
continue;
|
||
}
|
||
|
||
if (initial_pending && worker_is_idle(&worker)) {
|
||
if (worker_submit(&worker, initial_pending)) {
|
||
free(initial_pending);
|
||
initial_pending = NULL;
|
||
}
|
||
}
|
||
|
||
if (!initial_pending && queue.len && worker_is_idle(&worker)) {
|
||
char *echo = agent_prompt_queue_take_all_echo(&queue);
|
||
char *queued = agent_prompt_queue_take_all(&queue);
|
||
if (worker_submit(&worker, queued)) {
|
||
linenoiseHistoryAdd(queued);
|
||
linenoiseHistorySave(hist);
|
||
build_footer_text(&st, &queue, footer_cols, statusline, sizeof(statusline));
|
||
if (echo)
|
||
editor_write_async(&editor, echo, strlen(echo), prompt, statusline, true);
|
||
} else {
|
||
agent_prompt_queue_push_front(&queue, queued);
|
||
queued = NULL;
|
||
}
|
||
free(echo);
|
||
free(queued);
|
||
}
|
||
|
||
if (queue.len && editor_take_queued_byte(&editor, 24)) { /* Ctrl+X */
|
||
char *queued = agent_prompt_queue_pop(&queue);
|
||
editor_replace_input(&editor, queued);
|
||
worker_get_status(&worker, &st);
|
||
build_prompt_text(&st, prompt, sizeof(prompt));
|
||
footer_cols = editor.edit.cols > 0 ? (int)editor.edit.cols : 80;
|
||
build_footer_text(&st, &queue, footer_cols, statusline, sizeof(statusline));
|
||
editor_set_prompt_status(&editor, prompt, statusline);
|
||
free(queued);
|
||
}
|
||
if (queue.len && !worker_is_idle(&worker) && editor_take_bare_escape(&editor)) {
|
||
worker_interrupt(&worker);
|
||
}
|
||
|
||
if (!editor.paste_open && !editor.paste_start_pending &&
|
||
linenoiseEditQueuedInput(&editor.edit) > 0)
|
||
{
|
||
if (editor.hidden) {
|
||
/* A user key while the model is in the middle of a partial
|
||
* output line means the prompt must become visible again. End
|
||
* the model line explicitly; otherwise linenoise would redraw
|
||
* on top of generated text. */
|
||
editor_show(&editor);
|
||
}
|
||
errno = 0;
|
||
char *line = linenoiseEditFeed(&editor.edit);
|
||
if (line == linenoiseEditMore) {
|
||
/* Still editing. */
|
||
} else if (!line) {
|
||
if (errno == EAGAIN) {
|
||
if (!worker_is_idle(&worker)) {
|
||
worker_interrupt(&worker);
|
||
} else {
|
||
editor_cancel_input_with_hint(&editor, prompt, statusline);
|
||
}
|
||
} else {
|
||
running = false;
|
||
}
|
||
} else {
|
||
char *cmd = line;
|
||
while (*cmd == ' ' || *cmd == '\t' || *cmd == '\r' || *cmd == '\n') cmd++;
|
||
char *end = cmd + strlen(cmd);
|
||
while (end > cmd && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) end--;
|
||
*end = '\0';
|
||
|
||
bool was_below_output = editor.prompt_below_output;
|
||
bool had_output_line_open = editor.output_line_open;
|
||
int saved_output_col = editor.output_col;
|
||
editor_stop(&editor);
|
||
bool busy = !worker_is_idle(&worker);
|
||
if (!cmd[0]) {
|
||
/* Empty input: just reopen the editor. */
|
||
} else if (!strcmp(cmd, "/help")) {
|
||
runtime_help();
|
||
} else if (!strcmp(cmd, "/save")) {
|
||
if (busy) {
|
||
worker_request_save(&worker);
|
||
printf("save scheduled at next safe point\n");
|
||
} else {
|
||
char err[160] = {0};
|
||
if (!agent_worker_save_session(&worker, err, sizeof(err)))
|
||
printf("save failed: %s\n", err);
|
||
}
|
||
} else if (!strcmp(cmd, "/compact")) {
|
||
worker_request_compact(&worker);
|
||
if (busy)
|
||
printf("compaction scheduled at next safe point\n");
|
||
} else if (!strcmp(cmd, "/list")) {
|
||
agent_worker_list_sessions(&worker);
|
||
} else if (!strncmp(cmd, "/power", 6) &&
|
||
(cmd[6] == '\0' || cmd[6] == ' ' || cmd[6] == '\t')) {
|
||
char *arg = cmd + 6;
|
||
while (*arg == ' ' || *arg == '\t') arg++;
|
||
if (!arg[0]) {
|
||
printf("usage: /power <1..100>\n");
|
||
} else {
|
||
int power = 0;
|
||
if (!parse_power_percent(arg, &power)) {
|
||
printf("usage: /power <1..100>\n");
|
||
} else {
|
||
worker_request_power(&worker, power);
|
||
}
|
||
}
|
||
} else if (cmd[0] == '/' && !agent_slash_command_known(cmd)) {
|
||
ssize_t ignored = write(STDOUT_FILENO, "\a", 1);
|
||
(void)ignored;
|
||
restore_line = xstrdup(cmd);
|
||
} else if (cmd[0] == '/' && busy) {
|
||
printf("command requires the model to be idle: %s\n", cmd);
|
||
} else if (!strcmp(cmd, "/quit") || !strcmp(cmd, "/exit")) {
|
||
/* Stop the editor so raw mode and non-blocking stdin are
|
||
* disabled before we prompt the user. Then restore the
|
||
* ANSI scroll region too; AGENT_EXIT_NOW exits directly. */
|
||
editor_stop(&editor);
|
||
editor_restore_terminal_layout(&editor);
|
||
agent_exit_save_result exit_save =
|
||
agent_maybe_save_before_exiting(&worker);
|
||
if (exit_save == AGENT_EXIT_NOW) {
|
||
exit(0);
|
||
} else if (exit_save == AGENT_EXIT_CLEAN) {
|
||
exit_save_handled = true;
|
||
running = false;
|
||
} else {
|
||
/* AGENT_EXIT_CANCEL: user declined to proceed after a
|
||
* save failure. Reopen the editor and continue. */
|
||
editor_start(&editor, prompt, statusline, NULL);
|
||
}
|
||
} else if (!strcmp(cmd, "/new")) {
|
||
editor_restore_terminal_layout(&editor);
|
||
if (agent_maybe_save_before_leaving_session(&worker)) {
|
||
char err[160] = {0};
|
||
if (!agent_worker_reset_to_sysprompt(&worker, err, sizeof(err))) {
|
||
printf("new session failed: %s\n", err);
|
||
} else {
|
||
show_welcome_after_restart = true;
|
||
}
|
||
}
|
||
} else if (!strncmp(cmd, "/switch", 7) &&
|
||
(cmd[7] == '\0' || cmd[7] == ' ' || cmd[7] == '\t')) {
|
||
char *arg = cmd + 7;
|
||
while (*arg == ' ' || *arg == '\t') arg++;
|
||
if (!arg[0]) {
|
||
printf("usage: /switch <sha-prefix>\n");
|
||
} else {
|
||
editor_restore_terminal_layout(&editor);
|
||
if (agent_maybe_save_before_leaving_session(&worker)) {
|
||
char *sha = arg;
|
||
while (*arg && *arg != ' ' && *arg != '\t') arg++;
|
||
if (*arg) *arg = '\0';
|
||
char err[160] = {0};
|
||
if (!agent_worker_switch_session(&worker, sha,
|
||
AGENT_HISTORY_DEFAULT_TURNS,
|
||
err, sizeof(err)))
|
||
printf("switch failed: %s\n", err);
|
||
else
|
||
force_status_redraw_after_restart = true;
|
||
}
|
||
}
|
||
} else if (!strncmp(cmd, "/del", 4) &&
|
||
(cmd[4] == '\0' || cmd[4] == ' ' || cmd[4] == '\t')) {
|
||
char *arg = cmd + 4;
|
||
while (*arg == ' ' || *arg == '\t') arg++;
|
||
if (!arg[0]) {
|
||
printf("usage: /del <sha-prefix>\n");
|
||
} else {
|
||
char *sha_arg = arg;
|
||
while (*arg && *arg != ' ' && *arg != '\t') arg++;
|
||
if (*arg) *arg = '\0';
|
||
char sha[41] = {0};
|
||
char err[160] = {0};
|
||
if (agent_worker_delete_session(&worker, sha_arg,
|
||
sha, err, sizeof(err)))
|
||
printf("deleted session %.8s\n", sha);
|
||
else
|
||
printf("delete failed: %s\n", err);
|
||
}
|
||
} else if (!strncmp(cmd, "/strip", 6) &&
|
||
(cmd[6] == '\0' || cmd[6] == ' ' || cmd[6] == '\t')) {
|
||
char *arg = cmd + 6;
|
||
while (*arg == ' ' || *arg == '\t') arg++;
|
||
if (!arg[0]) {
|
||
printf("usage: /strip <sha-prefix>\n");
|
||
} else {
|
||
char *sha_arg = arg;
|
||
while (*arg && *arg != ' ' && *arg != '\t') arg++;
|
||
if (*arg) *arg = '\0';
|
||
char sha[41] = {0};
|
||
uint32_t tokens = 0;
|
||
char err[160] = {0};
|
||
if (agent_worker_strip_session(&worker, sha_arg,
|
||
sha, &tokens,
|
||
err, sizeof(err)))
|
||
printf("stripped session %.8s (%u tokens)\n",
|
||
sha, tokens);
|
||
else
|
||
printf("strip failed: %s\n", err);
|
||
}
|
||
} else if (!strncmp(cmd, "/history", 8) &&
|
||
(cmd[8] == '\0' || cmd[8] == ' ' || cmd[8] == '\t')) {
|
||
char *arg = cmd + 8;
|
||
while (*arg == ' ' || *arg == '\t') arg++;
|
||
int history_turns = arg[0] ?
|
||
agent_parse_int_default(arg, AGENT_HISTORY_DEFAULT_TURNS,
|
||
1, AGENT_HISTORY_MAX_TURNS) :
|
||
AGENT_HISTORY_DEFAULT_TURNS;
|
||
char err[160] = {0};
|
||
if (!agent_worker_show_history(&worker, history_turns,
|
||
err, sizeof(err)))
|
||
printf("history failed: %s\n", err);
|
||
} else if (busy) {
|
||
agent_prompt_queue_push(&queue, cmd);
|
||
} else {
|
||
linenoiseHistoryAdd(cmd);
|
||
linenoiseHistorySave(hist);
|
||
if (worker_submit(&worker, cmd)) {
|
||
agent_echo_user_prompt(cmd);
|
||
} else {
|
||
restore_line = xstrdup(cmd);
|
||
}
|
||
}
|
||
linenoiseFree(line);
|
||
|
||
if (running) {
|
||
worker_get_status(&worker, &st);
|
||
build_prompt_text(&st, prompt, sizeof(prompt));
|
||
int restart_cols = editor.edit.cols > 0 ? (int)editor.edit.cols : 80;
|
||
build_footer_text(&st, &queue, restart_cols, statusline, sizeof(statusline));
|
||
editor_start(&editor, prompt, statusline, restore_line);
|
||
if (!editor.scroll_region && was_below_output) {
|
||
editor.output_line_open = had_output_line_open;
|
||
editor.prompt_below_output = was_below_output;
|
||
editor.output_col = saved_output_col;
|
||
}
|
||
if (show_welcome_after_restart) {
|
||
editor_write_welcome_banner(&editor, cfg, prompt, statusline);
|
||
show_welcome_after_restart = false;
|
||
}
|
||
if (force_status_redraw_after_restart) {
|
||
editor_write_async(&editor, "", 0, prompt, statusline, true);
|
||
force_status_redraw_after_restart = false;
|
||
}
|
||
free(restore_line);
|
||
restore_line = NULL;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
free(initial_pending);
|
||
free(restore_line);
|
||
agent_prompt_queue_free(&queue);
|
||
editor_stop(&editor);
|
||
editor_restore_terminal_layout(&editor);
|
||
linenoiseSetCompletionCallback(NULL);
|
||
agent_completion_worker = NULL;
|
||
if (!exit_save_handled) {
|
||
agent_exit_save_result exit_save =
|
||
agent_maybe_save_before_exiting(&worker);
|
||
if (exit_save == AGENT_EXIT_NOW) exit(0);
|
||
}
|
||
agent_worker_free(&worker);
|
||
return 0;
|
||
}
|
||
|
||
#ifndef DS4_AGENT_TEST_NO_MAIN
|
||
int main(int argc, char **argv) {
|
||
agent_config cfg = parse_options(argc, argv);
|
||
if (cfg.chdir_path && chdir(cfg.chdir_path) != 0) {
|
||
fprintf(stderr, "ds4-agent: failed to chdir to %s: %s\n",
|
||
cfg.chdir_path, strerror(errno));
|
||
return 1;
|
||
}
|
||
ds4_engine *engine = NULL;
|
||
if (ds4_engine_open(&engine, &cfg.engine) != 0) return 1;
|
||
log_context_memory(cfg.engine.backend,
|
||
cfg.gen.ctx_size,
|
||
cfg.engine.prefill_chunk);
|
||
|
||
struct sigaction old_int;
|
||
struct sigaction sa;
|
||
memset(&sa, 0, sizeof(sa));
|
||
sigemptyset(&sa.sa_mask);
|
||
sa.sa_handler = agent_sigint_handler;
|
||
bool sigint_installed = !cfg.non_interactive &&
|
||
sigaction(SIGINT, &sa, &old_int) == 0;
|
||
|
||
int rc = cfg.non_interactive ?
|
||
run_agent_non_interactive(engine, &cfg) :
|
||
run_agent(engine, &cfg);
|
||
|
||
if (sigint_installed) sigaction(SIGINT, &old_int, NULL);
|
||
ds4_engine_close(engine);
|
||
return rc;
|
||
}
|
||
#endif
|