From 2fde898da08e3ec73796f9916c7a7a7bb4099d92 Mon Sep 17 00:00:00 2001 From: WHC Date: Mon, 5 Oct 2026 01:47:01 +0800 Subject: [PATCH] Usability pass: tool registry, TUI polish, saved keys and settings Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in /credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in /settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON. --- .gitea/workflows/ci.yml | 75 + .gitignore | 9 +- CMakeLists.txt | 63 +- Makefile | 131 +- README.md | 238 +- docs/compat.md | 94 + docs/env_openai.md | 78 +- docs/eval_tool_cst.md | 123 +- docs/theory_tool_cst.md | 67 + fixtures/ask_or_todo/TASK.md | 20 + fixtures/ask_or_todo/check.sh | 34 + fixtures/ask_or_todo/notes.txt | 3 + fixtures/big_file/Makefile | 5 + fixtures/big_file/TASK.md | 8 + fixtures/big_file/check.sh | 4 + fixtures/makefile_tab/TASK.md | 11 + fixtures/makefile_tab/check.sh | 10 + fixtures/makefile_tab/hello.c | 7 + fixtures/makefile_tab/tests/expect.txt | 2 + fixtures/mini_calc/check.sh | 5 + fixtures/multi_file_refactor/Makefile | 20 + fixtures/multi_file_refactor/TASK.md | 15 + fixtures/multi_file_refactor/check.sh | 11 + fixtures/multi_file_refactor/main.c | 19 + fixtures/multi_file_refactor/order.c | 17 + fixtures/multi_file_refactor/order.h | 11 + fixtures/multi_file_refactor/report.c | 12 + fixtures/multi_file_refactor/report.h | 8 + fixtures/multi_file_refactor/tests/run.sh | 28 + fixtures/snake_cpp/.baseline_green_ok | 0 fixtures/snake_cpp/check.sh | 5 + fixtures/test_fix_test/Makefile | 14 + fixtures/test_fix_test/TASK.md | 6 + fixtures/test_fix_test/check.sh | 12 + fixtures/test_fix_test/strutil.c | 23 + fixtures/test_fix_test/strutil.h | 7 + fixtures/test_fix_test/tests/test_strutil.c | 39 + include/cst_agent.h | 208 +- include/cst_metrics.h | 22 + include/cst_perms.h | 31 + include/cst_tools.h | 107 + include/cst_undo.h | 24 + include/cst_util.h | 36 + include/ctl.h | 27 + include/os_compat.h | 10 + scripts/bootstrap_linux.sh | 28 +- scripts/e2e_fixture.sh | 148 + scripts/e2e_mini_calc.sh | 121 +- scripts/run_matrix.sh | 63 + scripts/summarize.py | 186 + shell.nix | 14 + src/agent_loop.c | 2641 +++++++++----- src/agent_store.c | 132 + src/creds.c | 214 +- src/ctl_parse.c | 335 +- src/json_baseline.c | 154 +- src/llm_openai.c | 1348 ++++--- src/main.c | 372 +- src/metrics.c | 86 + src/mm_tool.c | 2 + src/models.c | 79 +- src/os_compat.c | 254 +- src/permissions.c | 223 ++ src/sandbox.c | 2 + src/session.c | 337 +- src/settings.c | 121 + src/tools/agent_tools.c | 108 + src/tools/diff.c | 280 ++ src/tools/fs_tools.c | 1178 ++++++ src/tools/patch.c | 317 ++ src/tools/registry.c | 342 ++ src/tools/tools.c | 1179 +++---- src/tools/tools_internal.h | 63 + src/tools/web.c | 285 ++ src/tui.c | 2968 ++++++++++++---- src/undo.c | 296 ++ src/util.c | 163 + .../sse/deepseek-ctl-heredoc/0000.sse | 136 + .../sse/deepseek-ctl-heredoc/0001.sse | 158 + .../sse/deepseek-ctl-heredoc/0002.sse | 128 + tests/fixtures/sse/deepseek-ctl/0000.sse | 60 + tests/fixtures/sse/deepseek-ctl/0001.sse | 136 + tests/fixtures/sse/deepseek-ctl/0002.sse | 136 + tests/fixtures/sse/deepseek-ctl/0003.sse | 136 + tests/fixtures/sse/deepseek-ctl/0004.sse | 88 + .../fixtures/sse/deepseek-native-eos/0000.sse | 64 + .../fixtures/sse/deepseek-native-eos/0001.sse | 96 + .../fixtures/sse/deepseek-native-eos/0002.sse | 118 + .../fixtures/sse/deepseek-native-eos/0003.sse | 48 + .../fixtures/sse/deepseek-native-eos/0004.sse | 152 + tests/test_apply_patch.c | 196 + tests/test_config.c | 146 + tests/test_ctl_heredoc.c | 204 ++ tests/test_diff.c | 229 ++ tests/test_llm_body.c | 174 + tests/test_registry.c | 114 + tests/test_replay_real.c | 295 ++ tests/test_session_roundtrip.c | 157 + tests/test_shell.c | 94 + tests/test_sse_decode.c | 301 ++ tests/test_tools_fs.c | 372 ++ tests/test_undo.c | 195 + vendor/cjson/LICENSE | 20 + vendor/cjson/cJSON.c | 3143 +++++++++++++++++ vendor/cjson/cJSON.h | 300 ++ 105 files changed, 19348 insertions(+), 3786 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 docs/compat.md create mode 100644 fixtures/ask_or_todo/TASK.md create mode 100755 fixtures/ask_or_todo/check.sh create mode 100644 fixtures/ask_or_todo/notes.txt create mode 100644 fixtures/big_file/Makefile create mode 100644 fixtures/big_file/TASK.md create mode 100755 fixtures/big_file/check.sh create mode 100644 fixtures/makefile_tab/TASK.md create mode 100755 fixtures/makefile_tab/check.sh create mode 100644 fixtures/makefile_tab/hello.c create mode 100644 fixtures/makefile_tab/tests/expect.txt create mode 100755 fixtures/mini_calc/check.sh create mode 100644 fixtures/multi_file_refactor/Makefile create mode 100644 fixtures/multi_file_refactor/TASK.md create mode 100755 fixtures/multi_file_refactor/check.sh create mode 100644 fixtures/multi_file_refactor/main.c create mode 100644 fixtures/multi_file_refactor/order.c create mode 100644 fixtures/multi_file_refactor/order.h create mode 100644 fixtures/multi_file_refactor/report.c create mode 100644 fixtures/multi_file_refactor/report.h create mode 100755 fixtures/multi_file_refactor/tests/run.sh create mode 100644 fixtures/snake_cpp/.baseline_green_ok create mode 100755 fixtures/snake_cpp/check.sh create mode 100644 fixtures/test_fix_test/Makefile create mode 100644 fixtures/test_fix_test/TASK.md create mode 100755 fixtures/test_fix_test/check.sh create mode 100644 fixtures/test_fix_test/strutil.c create mode 100644 fixtures/test_fix_test/strutil.h create mode 100644 fixtures/test_fix_test/tests/test_strutil.c create mode 100644 include/cst_metrics.h create mode 100644 include/cst_perms.h create mode 100644 include/cst_tools.h create mode 100644 include/cst_undo.h create mode 100644 include/cst_util.h create mode 100755 scripts/e2e_fixture.sh create mode 100755 scripts/run_matrix.sh create mode 100755 scripts/summarize.py create mode 100644 shell.nix create mode 100644 src/metrics.c create mode 100644 src/permissions.c create mode 100644 src/settings.c create mode 100644 src/tools/agent_tools.c create mode 100644 src/tools/diff.c create mode 100644 src/tools/fs_tools.c create mode 100644 src/tools/patch.c create mode 100644 src/tools/registry.c create mode 100644 src/tools/tools_internal.h create mode 100644 src/tools/web.c create mode 100644 src/undo.c create mode 100644 src/util.c create mode 100644 tests/fixtures/sse/deepseek-ctl-heredoc/0000.sse create mode 100644 tests/fixtures/sse/deepseek-ctl-heredoc/0001.sse create mode 100644 tests/fixtures/sse/deepseek-ctl-heredoc/0002.sse create mode 100644 tests/fixtures/sse/deepseek-ctl/0000.sse create mode 100644 tests/fixtures/sse/deepseek-ctl/0001.sse create mode 100644 tests/fixtures/sse/deepseek-ctl/0002.sse create mode 100644 tests/fixtures/sse/deepseek-ctl/0003.sse create mode 100644 tests/fixtures/sse/deepseek-ctl/0004.sse create mode 100644 tests/fixtures/sse/deepseek-native-eos/0000.sse create mode 100644 tests/fixtures/sse/deepseek-native-eos/0001.sse create mode 100644 tests/fixtures/sse/deepseek-native-eos/0002.sse create mode 100644 tests/fixtures/sse/deepseek-native-eos/0003.sse create mode 100644 tests/fixtures/sse/deepseek-native-eos/0004.sse create mode 100644 tests/test_apply_patch.c create mode 100644 tests/test_config.c create mode 100644 tests/test_ctl_heredoc.c create mode 100644 tests/test_diff.c create mode 100644 tests/test_llm_body.c create mode 100644 tests/test_registry.c create mode 100644 tests/test_replay_real.c create mode 100644 tests/test_session_roundtrip.c create mode 100644 tests/test_shell.c create mode 100644 tests/test_sse_decode.c create mode 100644 tests/test_tools_fs.c create mode 100644 tests/test_undo.c create mode 100644 vendor/cjson/LICENSE create mode 100644 vendor/cjson/cJSON.c create mode 100644 vendor/cjson/cJSON.h diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f39f205 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,75 @@ +name: ci + +on: + push: + pull_request: + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + cc: [gcc, clang] + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + SUDO=$(command -v sudo || true) + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config python3 \ + libcurl4-openssl-dev libncurses-dev + - name: Build (warnings are errors) + run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)" + - name: Unit tests + run: make CC=${{ matrix.cc }} check + - name: CMake build + ctest + run: | + cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON + cmake --build build -j"$(nproc)" + ctest --test-dir build --output-on-failure + - name: Smoke test + run: | + ./cst-agent --help + ./cst-agent --workspace "$PWD" --list-sessions + ./cst-agent --demo-ctl --workspace . + + sanitizers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + SUDO=$(command -v sudo || true) + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential pkg-config libcurl4-openssl-dev libncurses-dev + - name: ASan + UBSan + run: make asan + + musl: + runs-on: ubuntu-latest + container: alpine:3.20 + steps: + - name: Install dependencies + run: apk add --no-cache build-base pkgconf curl-dev ncurses-dev git nodejs bash python3 + - uses: actions/checkout@v4 + - name: Build + unit tests + run: | + make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)" + make check + + # Gitea instances rarely have macOS runners; set the repository variable + # HAVE_MACOS_RUNNER=true once one is registered. + macos: + if: ${{ vars.HAVE_MACOS_RUNNER == 'true' }} + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: brew install ncurses curl pkg-config + - name: Build + unit tests + run: | + make -j"$(sysctl -n hw.ncpu)" + make check diff --git a/.gitignore b/.gitignore index a808ffe..c561d09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,15 @@ tests/test_* !tests/test_*.c session.jsonl .agent/ -fixtures/mini_calc/out/ +out/ +fixtures/*/out/ fixtures/mini_calc/calc fixtures/snake_cpp/snake -fixtures/snake_cpp/out/ +fixtures/makefile_tab/hello +fixtures/makefile_tab/tests/actual.txt +fixtures/test_fix_test/test_strutil +fixtures/test_fix_test/.test_runs +fixtures/big_file/lines.txt build/ build-*/ dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index aa04d83..22165ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,12 +42,25 @@ add_custom_target(sashidown DEPENDS "${SASHIDOWN_A}") find_package(CURL REQUIRED) find_package(Threads REQUIRED) -set(CST_SOURCES +set(CJSON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/cjson") + +set(CST_CORE_SOURCES + vendor/cjson/cJSON.c + src/util.c src/ctl_parse.c src/os_compat.c src/sandbox.c src/session.c src/tools/tools.c + src/tools/patch.c + src/tools/registry.c + src/tools/fs_tools.c + src/tools/agent_tools.c + src/tools/web.c + src/tools/diff.c + src/undo.c + src/permissions.c + src/settings.c src/llm_openai.c src/agent_loop.c src/agent_store.c @@ -55,8 +68,9 @@ set(CST_SOURCES src/creds.c src/mm_tool.c src/models.c - src/main.c + src/metrics.c ) +set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) if(CST_BUILD_TUI) list(APPEND CST_SOURCES src/tui.c) @@ -70,6 +84,7 @@ add_dependencies(cst-agent sashidown) target_include_directories(cst-agent PRIVATE include "${SD_DIR}/include" + "${CJSON_DIR}" ) target_compile_definitions(cst-agent PRIVATE _GNU_SOURCE) @@ -128,19 +143,40 @@ endif() # --- tests --- enable_testing() -foreach(t test_ctl_frontier test_json_baseline test_replay_starvation test_mm_tool) - if(t STREQUAL test_ctl_frontier) - add_executable(${t} tests/test_ctl_frontier.c src/ctl_parse.c) +set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline + test_replay_starvation test_replay_real test_mm_tool) +foreach(t ${_CST_SMALL_TESTS}) + if(t STREQUAL test_ctl_frontier OR t STREQUAL test_ctl_heredoc) + add_executable(${t} tests/${t}.c src/ctl_parse.c) elseif(t STREQUAL test_json_baseline) - add_executable(${t} tests/test_json_baseline.c src/json_baseline.c) - elseif(t STREQUAL test_replay_starvation) - add_executable(${t} tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c) + add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c) + elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real) + add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c vendor/cjson/cJSON.c) else() add_executable(${t} tests/test_mm_tool.c src/mm_tool.c) endif() - target_include_directories(${t} PRIVATE include) + target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_compile_definitions(${t} PRIVATE _GNU_SOURCE) - add_test(NAME ${t} COMMAND ${t}) + if(NOT WIN32) + target_link_libraries(${t} PRIVATE m) + endif() + add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") +endforeach() + +# Tests that link the whole core (no TUI). +foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip + test_shell test_diff test_tools_fs test_registry + test_undo test_config) + add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) + target_include_directories(${t} PRIVATE include "${CJSON_DIR}") + target_compile_definitions(${t} PRIVATE _GNU_SOURCE) + target_link_libraries(${t} PRIVATE CURL::libcurl Threads::Threads) + if(NOT WIN32) + target_link_libraries(${t} PRIVATE m) + else() + target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32) + endif() + add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") endforeach() # --- busybox bundle helpers --- @@ -154,7 +190,12 @@ if(CST_BUNDLE_BUSYBOX) endif() endif() -install(TARGETS cst-agent RUNTIME DESTINATION .) +if(WIN32) + install(TARGETS cst-agent RUNTIME DESTINATION .) +else() + include(GNUInstallDirs) + install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif() message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}") message(STATUS "CST_BUILD_TUI=${CST_BUILD_TUI}") diff --git a/Makefile b/Makefile index aaf975e..88faf92 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,65 @@ CC ?= gcc CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g SD_DIR := vendor/sashidown -CPPFLAGS += -Iinclude -I$(SD_DIR)/include -D_XOPEN_SOURCE_EXTENDED -LDFLAGS += -L$(SD_DIR) -LDLIBS += -lsashidown -lm -lcurl -lncursesw -lpthread +CJSON_DIR := vendor/cjson +PKG_CONFIG ?= pkg-config +PREFIX ?= /usr/local +DESTDIR ?= +UNAME_S := $(shell uname -s 2>/dev/null) -SRC = \ +# macOS: Homebrew ncurses/curl are keg-only, so point pkg-config at them. +# (macOS ships GNU make 3.81, whose $(shell) ignores exported variables.) +PKG_ENV := +ifeq ($(UNAME_S),Darwin) +BREW_PREFIX := $(shell brew --prefix 2>/dev/null) +ifneq ($(BREW_PREFIX),) +PKG_ENV := PKG_CONFIG_PATH="$(BREW_PREFIX)/opt/ncurses/lib/pkgconfig:$(BREW_PREFIX)/opt/curl/lib/pkgconfig:$$PKG_CONFIG_PATH" +endif +CPPFLAGS += -D_DARWIN_C_SOURCE +endif +PKGC = $(PKG_ENV) $(PKG_CONFIG) + +# Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide). +CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done) +ifneq ($(CURSES_PC),) +CURSES_CFLAGS := $(shell $(PKGC) --cflags $(CURSES_PC)) +CURSES_LIBS := $(shell $(PKGC) --libs $(CURSES_PC)) +else ifeq ($(UNAME_S),Darwin) +CURSES_LIBS := -lncurses +else +CURSES_LIBS := -lncursesw +endif + +CURL_CFLAGS := $(shell $(PKGC) --cflags libcurl 2>/dev/null) +CURL_LIBS := $(shell $(PKGC) --libs libcurl 2>/dev/null) +ifeq ($(strip $(CURL_LIBS)),) +CURL_LIBS := -lcurl +endif + +PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) +PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) + +CPPFLAGS += -Iinclude -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS) +LDFLAGS += -L$(SD_DIR) +LDLIBS += -lsashidown -lm $(PKG_LIBS) -lpthread + +CORE_SRC = \ + $(CJSON_DIR)/cJSON.c \ + src/util.c \ src/ctl_parse.c \ src/os_compat.c \ src/sandbox.c \ src/session.c \ src/tools/tools.c \ + src/tools/patch.c \ + src/tools/registry.c \ + src/tools/fs_tools.c \ + src/tools/agent_tools.c \ + src/tools/web.c \ + src/tools/diff.c \ + src/undo.c \ + src/permissions.c \ + src/settings.c \ src/llm_openai.c \ src/agent_loop.c \ src/agent_store.c \ @@ -18,16 +67,36 @@ SRC = \ src/creds.c \ src/mm_tool.c \ src/models.c \ - src/tui.c \ - src/main.c + src/metrics.c +SRC = $(CORE_SRC) src/tui.c src/main.c OBJ = $(SRC:.c=.o) +HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h -.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang +TESTS = \ + tests/test_ctl_frontier \ + tests/test_ctl_heredoc \ + tests/test_json_baseline \ + tests/test_replay_starvation \ + tests/test_replay_real \ + tests/test_mm_tool \ + tests/test_llm_body \ + tests/test_sse_decode \ + tests/test_apply_patch \ + tests/test_session_roundtrip \ + tests/test_shell \ + tests/test_diff \ + tests/test_tools_fs \ + tests/test_registry \ + tests/test_undo \ + tests/test_config + +TEST_LIBS = -lm $(CURL_LIBS) -lpthread + +.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install uninstall all: sashidown-lib cst-agent -# Optional CMake entrypoints (same sources) cmake-gcc: cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON cmake --build build-gcc @@ -43,35 +112,51 @@ sashidown-lib: cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a $(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS) -src/%.o: src/%.c include/ctl.h include/cst_agent.h +%.o: %.c $(HDRS) $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< -src/tools/%.o: src/tools/%.c include/ctl.h include/cst_agent.h - $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< - -src/tui.o: src/tui.c include/cst_agent.h $(SD_DIR)/include/sashidown.h +src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c -tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c include/cst_agent.h - $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c +tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c -tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c - $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c +tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm + +tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm + +tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c -check: tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool - ./tests/test_ctl_frontier - ./tests/test_json_baseline - ./tests/test_replay_starvation - ./tests/test_mm_tool +tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) + +check: $(TESTS) + @set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done + +# AddressSanitizer + UBSan build of the agent and all tests. +asan: + $(MAKE) clean + $(MAKE) sashidown-lib + $(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" cst-agent check demo: cst-agent ./cst-agent --demo-ctl --workspace . clean: - rm -f $(OBJ) cst-agent tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool + rm -f $(OBJ) cst-agent $(TESTS) + +install: cst-agent + install -d "$(DESTDIR)$(PREFIX)/bin" + install -m 0755 cst-agent "$(DESTDIR)$(PREFIX)/bin/cst-agent" + +uninstall: + rm -f "$(DESTDIR)$(PREFIX)/bin/cst-agent" diff --git a/README.md b/README.md index 3c0e362..b88b390 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ See [docs/theory_tool_cst.md](docs/theory_tool_cst.md). ## 能不能用来工作? -**可以**:有监督的本地小改动 / 探索本仓库(TUI + 工具 + 权限确认)。 -**不宜**:无人值守长任务或当 CI 主力(网关偶发空答、无完整 diff UI)。 +**可以**:有监督的本地改动 / 探索仓库(TUI + 13 个内置工具 + 带 diff 的权限确认 + `/undo`)。 +**不宜**:无人值守长任务或当 CI 主力(网关偶发空答;headless 模式默认自动批准)。 ### 5 分钟试用 @@ -19,13 +19,16 @@ git clone https://git.whcself.xyz/WHC/CST-agent.git && ./CST-agent/scripts/boots # 已有仓库目录内: ./scripts/bootstrap_linux.sh . -# OpenAI 官方 -export OPENAI_API_KEY='sk-...' -export OPENAI_BASE_URL='https://api.openai.com/v1' -export OPENAI_MODEL='gpt-4.1-mini' -./cst-agent --workspace . +# 装到 PATH(或 sudo make install) +make install PREFIX=$HOME/.local + +# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存) +cd ~/my-project && cst-agent ``` +之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。 +界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。 + 完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。 | 平台 | 一键脚本 | @@ -41,21 +44,41 @@ export OPENAI_MODEL='gpt-4.1-mini' ```bash ./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED -./scripts/e2e_mini_calc.sh # 有 key:agent 修复后 make test +./scripts/e2e_mini_calc.sh --provider deepseek --protocol ctl # 有 key ``` -口径:`pass` / `partial` / `fail` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。 +更多夹具(每个都有 `TASK.md` + `check.sh`):`makefile_tab`(写含 TAB 的 Makefile)、 +`big_file`(写 5000 字节文件)、`test_fix_test`(先跑测试→修→再跑测试)、 +`multi_file_refactor`(跨文件改名 + 修 bug,适合 `edit_file` / `grep`)、 +`ask_or_todo`(必须用 `todo_write` 规划并全部完成;无人回答 `ask_user`)。 +后两个需要 `--tools default`(脚本默认 `minimal`,即论文用的 4 个工具)。 + +```bash +scripts/e2e_fixture.sh makefile_tab --provider deepseek --protocol native-inc +scripts/e2e_fixture.sh multi_file_refactor --provider deepseek --tools default +# 批量实验:协议 x 模型 x 夹具 x N 次 → results.csv + summary.md +scripts/run_matrix.sh -p "native-eos native-inc ctl ctl-heredoc xml" \ + -m "deepseek:deepseek-flash deepseek:deepseek-v4-pro" -n 3 -r +``` + +口径:`pass` / `fail` / `agent_error` / `timeout` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。 ## Build ### Make(Linux 默认) ```bash -cd agent -make -make check +cd CST-agent +make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses) +make check # 单元测试 + 截断实验(含真实录制流) +make asan # AddressSanitizer + UBSan 构建并跑全部测试 +make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local) ``` +NixOS:`nix-shell`(仓库自带 [`shell.nix`](shell.nix))后再 `make`。 +macOS:`brew install curl ncurses pkg-config` 后直接 `make`(Makefile 会找 Homebrew 的 keg-only 库)。 +各平台细节与验收清单见 [docs/compat.md](docs/compat.md)。 + ### CMake(推荐跨编译器 / 交叉) ```bash @@ -77,17 +100,20 @@ cmake --build build-win # TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=... ``` -依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**;sibling `../SD`(`libsashidown.a`)。 -API Key:env / 配置文件 / **zenity(Linux)或控制台输入(Windows / 无 zenity)**。 +依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**。 +Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。 +API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。 +`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。 ## 平台支持矩阵 | OS | Arch | 编译器 | Headless | TUI | Shell | |---|---|---|---|---|---| -| Linux | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` | +| Linux(glibc / musl) | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` | +| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) | +| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` | | Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox | | Windows | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** | -| macOS | — | — | **不做** | — | — | ### Termux @@ -118,65 +144,159 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ ## Defaults (API) -默认 MiniMax:`https://api.minimax.cn/v1` / `MiniMax-M2.7`。 -对接 **OpenAI 官方或其它兼容网关** 时设置: +`--provider`(或 `CST_PROVIDER`)选择预设;显式的 `--api-base` / `--model` / 环境变量优先。 -| 用途 | 推荐变量 | 等价别名 | -|---|---|---| -| Key | `OPENAI_API_KEY` | `CST_API_KEY`、`MINIMAX_API_KEY` | -| Base(含 `/v1`) | `OPENAI_BASE_URL` | `CST_API_BASE`、`OPENAI_API_BASE` | -| Model | `OPENAI_MODEL` | `CST_MODEL` | +| provider | API Base | 默认模型 | Key 变量 | +|---|---|---|---| +| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | +| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | +| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | + +| 用途 | 来源(优先级从高到低) | +|---|---| +| Provider | `--provider` → `CST_PROVIDER` → 上次使用 → `minimax` | +| Key | `--api-key` → 已保存的 key(`credentials`)→ provider 的 Key 变量 → `CST_API_KEY` → TUI 询问 | +| Base(含 `/v1`) | `--api-base` → `CST_API_BASE` → `OPENAI_BASE_URL` → `OPENAI_API_BASE` → 预设 | +| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 | +| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` | + +“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。 +推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。 详见 [docs/env_openai.md](docs/env_openai.md)。 -| 项 | 默认(未设 OPENAI_* 时) | +| 变量 | 作用(默认) | |---|---| -| API Base | `https://api.minimax.cn/v1` | -| Model | `MiniMax-M2.7` | -| API Key | env → 配置目录 credentials → zenity/控制台 | - -环境变量:`CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR`。 +| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) | +| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) | +| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` | +| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` | +| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | +| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) | +| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) | +| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) | +| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt | +| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) | +| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | +| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/.jsonl`) | +| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | +| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准、shell、旁路 busybox | ## Run ```bash +# TUI(工作目录 = 当前目录;Assistant Markdown 经 Sashidown CST 渲染) +cst-agent + # Local CTL demo (no API) -./cst-agent --demo-ctl --workspace . +cst-agent --demo-ctl -# TUI(OpenCode 风格;Assistant Markdown 经 Sashidown CST 渲染) -./cst-agent --workspace .. - -# 强制重新弹窗保存 Key -./cst-agent --ask-key --workspace . +# 在命令行输入并保存当前 provider 的 key(不回显) +cst-agent --ask-key --provider deepseek # Headless(默认 auto-approve shell/写文件) -./cst-agent --headless --prompt 'List files' --workspace . +cst-agent --headless --prompt 'List files' ``` -### TUI 快捷键与斜杠命令 +会话相关命令行参数: -| 操作 | 说明 | +| 参数 | 作用 | |---|---| -| Enter | 发送;空输入展开最近 tool 卡;busy 时入队(最多 3) | -| Ctrl+N | 输入换行(多行 composer) | -| ↑ / ↓ | 提示历史 | -| Esc / Ctrl+C | busy 时取消当前 turn | -| Tab / `t`(空输入) | 展开/折叠 Thought | -| 权限 | shell/写文件:`y` 允许 · `n` 拒绝 · `a` 本进程对该工具 Always | -| `/new` | 新会话 | -| `/session` | **可点选**会话列表(↑↓ Enter);`/session ` 手打切换 | -| `/model` | **可点选**模型;`/model NAME` 直接设置(进程内) | -| `/compact` | 压缩上下文 | -| `/details` | 展开/折叠全部 tool 输出 | -| `/help` | 帮助 | +| `-c` / `--continue` | 打开上次的会话(默认行为) | +| `-N` / `--new` | 开新会话 | +| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) | +| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 | +| `-y` / `--yolo` | 本次运行不再确认(等同 `--confirm none`) | +| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | +| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` | +| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) | +| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key | -会话落盘:`/.agent/sessions/.jsonl` + `CURRENT`。 +工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。 + +会话落盘:`/.agent/sessions/.jsonl` + `CURRENT`;`.agent/` 自带 `.gitignore`,不会被误提交。 + +### 内置工具 + +| 工具 | 类别 | 说明 | +|---|---|---| +| `read_file` | 读 | 支持 `offset` / `limit` 分段读和行号 | +| `list_dir` | 读 | 目录树(`depth`),遵守 `.gitignore`,跳过 `.git` / `node_modules` 等 | +| `glob` | 读 | 按通配符找文件(支持 `**`) | +| `grep` | 读 | 正则搜索内容,可限定路径 / glob、忽略大小写、带上下文 | +| `write_file` | 写 | 整文件写入(自动建父目录) | +| `edit_file` | 写 | 精确片段替换(必须唯一,或 `replace_all`) | +| `apply_patch` | 写 | 应用 unified diff,多文件原子生效 | +| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | +| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | +| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | +| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) | +| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | + +`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。 +TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 + +### 权限、撤销与项目说明 + +- **确认**:默认 shell、写文件、联网三类工具需要确认。弹窗会显示命令或写入的 diff: + `y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。 + `a` / `p` 写入 `/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。 + `sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。 +- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。 +- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。 +- **自动压缩**:上一轮 prompt 用到模型上下文 80% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。 + +### TUI 快捷键 + +| 按键 | 说明 | +|---|---| +| Enter | 发送;busy 时入队 | +| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) | +| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) | +| Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 | +| ↑ / ↓ | 提示历史(多行输入时先在行间移动) | +| Tab | 补全 `/命令` 和 `@文件`;空输入时展开/折叠 Thought | +| Ctrl+O / Ctrl+T | 展开最近 tool 输出 / 开关任务面板 | +| PgUp / PgDn / 鼠标滚轮 | 滚动对话 | +| Esc | 取消当前 turn(不会退出) | +| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 | + +### 斜杠命令 + +输入 `/` 弹出补全;唯一前缀即可(如 `/st` → `/status`)。标 * 的命令在模型运行中也能用。 + +| 命令 | 说明 | +|---|---| +| `/help` * | 命令与按键 | +| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 | +| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入)、模型、推理强度;都会记住 | +| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) | +| `/protocol [name]` | 切换工具调用协议 | +| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 | +| `/permissions [reset]` *、`/yolo` *、`/max-steps [N]` * | 权限规则、本次免确认、每轮步数 | +| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 | +| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 | +| `/diff [path]` * | `git status` + `git diff` | +| `/compact` | 手动压缩上下文 | +| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | +| `/init` | 让 agent 写 `AGENTS.md` | +| `/details` *、`/quit` * | 展开全部 tool 输出;退出 | ### 协议 -- OpenAI 兼容 `POST …/chat/completions`:原生 `tools` / SSE `tool_calls`,answer 轮 `tool_choice:none` -- MiniMax XML `` 作 fallback -- Assistant 仅工具时 `content: null` + `tool_calls`(对齐 OpenCode/OpenAI Chat) +请求统一走 OpenAI 兼容 `POST …/chat/completions`(SSE)。工具调用的承载方式由 `--protocol` 决定: + +| 协议 | 承载 | 何时执行 | +|---|---|---| +| `native-eos`(默认) | 原生 `tools[]` / SSE `tool_calls` | 整个流结束后(基线) | +| `native-inc` | 同上 | 某个 index 的参数完整(下一个 index 开始或 finish)即执行 | +| `ctl` | 正文中的 CTL 帧,长度前缀参数 | `end` 行一被确认即执行,流仍在继续 | +| `ctl-heredoc` | 正文中的 CTL 帧,`<` 文本 | 流结束后 | + +文本协议(ctl / ctl-heredoc / xml)不发送 `tools[]`,工具结果以 +`result exit= ` … `end` 帧放在下一条 user 消息里。 +同一会话可以随时切换协议:历史中的调用会按当前协议重新渲染。 ## CTL tool language @@ -184,16 +304,22 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ call arg +arg < +TAG +arg short inline value end ``` -Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`). +Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`); no prefix of +a call is ever executable. See [docs/theory_tool_cst.md](docs/theory_tool_cst.md). ## Starvation experiment ```bash make check ./scripts/replay_starvation.sh +./tests/test_replay_real # 真实 DeepSeek 录制流上的逐字节截断(实验 C) ``` -CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。 +CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A / C;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。 diff --git a/docs/compat.md b/docs/compat.md new file mode 100644 index 0000000..dad72f4 --- /dev/null +++ b/docs/compat.md @@ -0,0 +1,94 @@ +# Platform support: Linux, WSL, macOS + +CST-Agent targets POSIX systems: Linux (glibc and musl), WSL 2 and macOS. +Native Windows builds are covered separately in `README-windows.md`. + +## Build + +| Platform | Dependencies | Build | +|---|---|---| +| Debian / Ubuntu / WSL | `build-essential pkg-config libcurl4-openssl-dev libncurses-dev` | `make` | +| Fedora | `gcc make pkgconf libcurl-devel ncurses-devel` | `make` | +| Arch | `base-devel curl ncurses` | `make` | +| Alpine (musl) | `build-base pkgconf curl-dev ncurses-dev` | `make` | +| NixOS | `nix-shell` (uses `shell.nix`) | `make` | +| macOS | `xcode-select --install`, `brew install ncurses curl pkg-config` | `make` | + +`make install PREFIX=~/.local` installs the binary; CMake works too +(`cmake -B build && cmake --build build && ctest --test-dir build`). + +The Makefile picks `ncursesw`, then `ncurses`, through pkg-config. On macOS it +adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without +Homebrew it falls back to the system `-lncurses` (wide-character capable). + +Verified so far: NixOS x86_64 with gcc and clang (`-Werror`, `make check`, +`make asan`, ctest), and every source compiled `-fsyntax-only` with +`x86_64-unknown-linux-musl-gcc` without warnings. Full musl, macOS and WSL +builds run in CI (`.gitea/workflows/ci.yml`; the macOS job needs a runner and +the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below. + +## Files + +| What | Where | +|---|---| +| API keys (one per provider, entered in the TUI or with `--ask-key`) | `$XDG_CONFIG_HOME/cst-agent/credentials` (default `~/.config/cst-agent/`), mode 0600 | +| Last provider, models and effort | `$XDG_CONFIG_HOME/cst-agent/settings.json` | +| Sessions, undo snapshots, exports | `/.agent/` (git-ignored automatically) | +| Saved permission rules | `/.agent/permissions.json` | +| Project notes injected into the prompt | `/AGENTS.md` (or `CST.md`) | + +## Terminal notes + +- UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with + `CST_ASCII=1`, box and icon glyphs fall back to ASCII. +- `NO_COLOR=1` turns colours off. +- `/copy` uses OSC 52. iTerm2 needs "Applications in terminal may access + clipboard"; tmux needs `set -g set-clipboard on`; Windows Terminal (WSL) + supports it out of the box. +- Bracketed paste needs ncurses (all platforms above). +- macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as + Meta key"; Ctrl+N always inserts a newline. + +## Acceptance checklist + +Run on each platform before a release. Use a scratch git repository as the +workspace and a real API key (entered when the TUI asks for it). + +Build and tests: + +- [ ] `make` has no warnings; `make check` passes +- [ ] `make asan` passes (Linux; macOS: `make CFLAGS="-fsanitize=address,undefined -g" check`) +- [ ] `cmake -B build && cmake --build build && ctest --test-dir build` passes +- [ ] `make install PREFIX=/tmp/cst && /tmp/cst/bin/cst-agent --help` + +CLI: + +- [ ] `--list-sessions`, `--new`, `--resume `, `--continue` behave as documented +- [ ] `--headless --prompt "list the files"` prints a transcript and exits 0 +- [ ] First start asks for a provider and key; the credentials file is mode 0600 +- [ ] Second start reuses provider, model and effort without asking + +TUI: + +- [ ] Typing Chinese/emoji keeps the cursor in the right column; ←→ Home End work +- [ ] Pasting a multi-line block does not send it; Enter sends +- [ ] `/` shows the command popup, Tab completes, `@` completes file paths +- [ ] Esc cancels a running turn; Esc on an empty prompt does nothing; Ctrl+C twice quits +- [ ] A long `run_shell` (e.g. `sleep 30`) stops promptly on Esc +- [ ] Write permission shows the diff; `a` saves a rule; `p` saves a shell prefix rule +- [ ] `ask_user` modal accepts a number, an arrow selection and typed text +- [ ] Todo panel appears while tasks are open; Ctrl+T hides it +- [ ] `/undo` restores files changed by the last turn; `/retry` re-asks +- [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work +- [ ] Resize the terminal during streaming: layout recovers +- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens + +WSL specifics: + +- [ ] Workspace under `/mnt/c/...` works (slower IO is expected); `~/project` is recommended +- [ ] Windows Terminal: mouse wheel scroll and OSC 52 `/copy` + +macOS specifics: + +- [ ] Builds with Homebrew ncurses and with the system ncurses (`brew unlink ncurses`) +- [ ] Terminal.app and iTerm2: arrows, Home/End, PgUp/PgDn, bracketed paste diff --git a/docs/env_openai.md b/docs/env_openai.md index a85879b..518f927 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -1,47 +1,56 @@ # OpenAI 兼容 API 环境变量 -CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completions`), -可对接官方 OpenAI、MiniMax、以及其它兼容网关。 +CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completions`,SSE 流式), +可对接官方 OpenAI、DeepSeek、MiniMax,以及其它兼容网关。 -## 必填 / 常用 +## Provider 预设 -| 变量 | 别名 | 说明 | 示例 | +`--provider NAME`(或 `CST_PROVIDER`,或 TUI 里的 `/provider`)一次性给出 base 和默认模型。 +不传时沿用上次使用的 provider(记在配置目录的 `settings.json`)。 + +| provider | API Base | 默认模型 | Key 变量 | |---|---|---|---| -| `CST_API_KEY` | `OPENAI_API_KEY`、`MINIMAX_API_KEY` | API Key(按此优先级读取) | `sk-...` | -| `CST_API_BASE` | `OPENAI_BASE_URL`、`OPENAI_API_BASE` | API 根路径,须含 `/v1`(不要带 `/chat/completions`) | `https://api.openai.com/v1` | -| `CST_MODEL` | `OPENAI_MODEL` | 模型名 | `gpt-4.1-mini` / `MiniMax-M2.7` | +| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | +| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | +| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | -未设置 key 时:读配置文件 →(Linux)zenity / 控制台输入。 +显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。 -配置文件: -- Linux/Termux:`~/.config/cst-agent/credentials` +## 解析顺序 + +| 项 | 优先级(高 → 低) | +|---|---| +| Key | `--api-key`(**只用于本次进程,不写盘**)→ 该 provider 已保存的 key → provider 的 Key 变量 → `CST_API_KEY` → TUI 弹出遮罩输入框(输入后保存) | +| Base(含 `/v1`,不带 `/chat/completions`) | `--api-base` → `CST_API_BASE` → `OPENAI_BASE_URL` → `OPENAI_API_BASE` → 预设 | +| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 | +| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) | + +key 按 provider 保存在 credentials 文件(权限 0600),每行 `.api_key=...`; +TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显): +- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`) - Windows:`%APPDATA%\cst-agent\credentials` +旧版只有一行 `api_key=` 的文件仍可读:按其中的 `api_base` 归到对应 provider(没有 `api_base` 时视为 MiniMax)。 + ## 示例 +### DeepSeek + +```bash +cst-agent --provider deepseek # 首次会询问 key;默认 deepseek-flash +cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought +``` + ### OpenAI 官方 ```bash -export OPENAI_API_KEY='sk-...' -export OPENAI_BASE_URL='https://api.openai.com/v1' -export OPENAI_MODEL='gpt-4.1-mini' -./cst-agent --workspace . -``` - -等价 CST 前缀: - -```bash -export CST_API_KEY='sk-...' -export CST_API_BASE='https://api.openai.com/v1' -export CST_MODEL='gpt-4.1-mini' +cst-agent --provider openai ``` ### MiniMax(默认) ```bash -export CST_API_KEY='...' # 或 MINIMAX_API_KEY -# 默认 base=https://api.minimax.cn/v1 model=MiniMax-M2.7 -./cst-agent --workspace . +cst-agent --provider minimax ``` ### 任意兼容网关 @@ -53,21 +62,34 @@ export OPENAI_MODEL='your-model' ./cst-agent --headless --prompt 'ping' --workspace . ``` -命令行也可覆盖:`--api-base` / `--api-key` / `--model`。 +网关不支持 `stream_options.include_usage` 时设 `CST_NO_STREAM_USAGE=1`。 ## 其它环境变量 | 变量 | 说明 | |---|---| +| `CST_PROTOCOL` | 工具调用协议:`native-eos`(默认)/ `native-inc` / `ctl` / `ctl-heredoc` / `xml` | +| `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) | +| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) | +| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) | +| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认 120) | +| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL | +| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 | | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | -| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 常用) | +| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 默认开启) | | `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) | | `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) | +网络:连接超时 30 秒;300 秒内无数据视为超时;429 / 5xx / 连接错误在尚未收到任何内容时自动重试 3 次(1s、2s、4s 退避)。 + ## CLI 对照 ```text +--provider NAME ← CST_PROVIDER --api-base URL ← CST_API_BASE / OPENAI_BASE_URL ---api-key KEY ← CST_API_KEY / OPENAI_API_KEY +--api-key KEY ← 已保存的 key / / CST_API_KEY --model NAME ← CST_MODEL / OPENAI_MODEL +--effort LEVEL ← CST_EFFORT +--protocol P ← CST_PROTOCOL +--max-steps N ← CST_MAX_STEPS ``` diff --git a/docs/eval_tool_cst.md b/docs/eval_tool_cst.md index 7ce5ec1..72a258a 100644 --- a/docs/eval_tool_cst.md +++ b/docs/eval_tool_cst.md @@ -10,18 +10,15 @@ | 层 | 主张 | 主变量 | 非主张 | |---|---|---|---| | 论文 CST | \(O_{\mathrm{conf}}\) 有界确认 → HTML | \(\mathcal{L}_0\) vs 引用式链接等 | — | -| Tool-loop CST | 同构到工具帧 | **CTL vs JSON-buffer** 截断 | 网关空答率、模型智商 | -| 工程载体 | OpenAI `tool_calls` + XML fallback | 可演示端到端 | 不算进 CST 正确性定理 | +| Tool-loop CST | 同构到工具帧 | **CTL vs JSON-buffer** 截断;确认即执行 | 网关空答率、模型智商 | +| 工程载体 | 五种协议同一 agent 循环 | 端到端通过率、步数、token、时延 | 不算进 CST 正确性定理 | -## 实验 A — 饥饿 / 截断(已自动化) +## 实验 A — 饥饿 / 截断(合成样例,已自动化) ```bash -cd agent && make check -# 关注 ./tests/test_replay_starvation +make check # 关注 tests/test_replay_starvation ``` -填写模板(跑完后更新数字): - | 指标 | CTL | JSON-buffer | |---|---|---| | 完整输入可执行 | yes | yes | @@ -29,45 +26,127 @@ cd agent && make check | 安全截断样本(本机 `make check`) | 3 | 3 次 trunc failure | 对应理论:裸 JSON ∉ \(\mathcal{L}_0^{\mathrm{tool}}\);CTL 长度前缀使参数体字节可确认,pending 有界。 +定界写法另有 `tests/test_ctl_heredoc`:对一个含 TAB、空行、`end`/`call`/围栏行和缩进 TAG 的体, +在**每一个**字节前缀上都不产生 `CALL_READY`,完整流上体逐字节还原。 ## 实验 B — mini_calc 端到端案例(不可逆发射) -协议见 [fixtures/mini_calc/](../fixtures/mini_calc/) 与 [scripts/e2e_mini_calc.sh](../scripts/e2e_mini_calc.sh)。 +协议见 [fixtures/mini_calc/](../fixtures/mini_calc/) 与 [scripts/e2e_fixture.sh](../scripts/e2e_fixture.sh)。 ```bash -cd agent -./scripts/e2e_mini_calc.sh --baseline-only # 无 key:证明 fixture 初始 RED -./scripts/e2e_mini_calc.sh # 有 key:agent 修 bug → make test +./scripts/e2e_mini_calc.sh --baseline-only # 无 key:证明 fixture 初始 RED +./scripts/e2e_mini_calc.sh --provider deepseek # 有 key:agent 修 bug → make test ``` ### 验收口径 +每个夹具有 `TASK.md`(任务书)和 `check.sh`(判定脚本)。基线在一份临时副本上先跑 `check.sh`,必须失败。 + | outcome | 含义 | |---|---| -| `pass` | `make test` 绿 | -| `partial` | 改了 `calc.c` 但测试仍红 | -| `fail` | 未改文件或 agent/API 失败 | -| `baseline_ok_agent_skipped` / `baseline_ok_no_api_key` | 仅验证夹具 | +| `pass` | agent 结束后 `check.sh` 通过 | +| `fail` | agent 正常结束但 `check.sh` 失败 | +| `agent_error` | agent 非零退出(如连续契约错误、API 错误) | +| `timeout` | 超过 `E2E_TIMEOUT`(默认 900 秒) | +| `unexpected_green_fixture` | 夹具一开始就通过(夹具坏了) | ### 论文可引用的叙事 -1. Session JSONL 中顺序:assistant `tool_calls` → `role:tool` 结果 →(可选)写文件/shell → 最终文本。 -2. 工具仅在确认/原生完整 arguments 后执行(工程桥);**CTL 饥饿实验**仍是形式主证据。 +1. Session JSONL 中顺序:assistant 调用 → 工具结果 → 最终文本;只追加。 +2. 工具仅在确认(CTL `end`、原生参数完整)后执行;**CTL 饥饿实验**仍是形式主证据。 3. 案例展示「确认后不可撤回的磁盘副作用」与 transcript 只追加。 -### 跑分记录(模板) +### 稳定性(M1,DeepSeek `deepseek-chat`,ASAN + UBSan 构建) -| 日期 (UTC) | model | outcome | notes | -|---|---|---|---| -| 2026-09-07 | MiniMax-M2.7 | **pass** | `e2e_mini_calc.sh`;baseline RED → agent 修 `add`/`div_ints` → `make test` 绿;见 `out/result-20260907T131300Z.txt` | +| 日期 (UTC) | 场景 | 结果 | +|---|---|---| +| 2026-09-07 | mini_calc,MiniMax-M2.7 | **pass**(旧版本,`out/result-20260907T131300Z.txt`) | +| 2026-10-04 | mini_calc 连续 10 次,native-eos | **10/10 pass**,ASAN/LSan 无报告 | +| 2026-10-04 | makefile_tab(写含 TAB 的 Makefile;旧版本此处 HTTP 400) | pass | +| 2026-10-04 | big_file(写 5000 字节文件;旧版本长内容被截断) | pass | +| 2026-10-04 | test_fix_test(先跑测试→修→再跑;旧版本第二次 `make test` 被错误去重) | pass,`make test` 实际执行 2 次 | +| 2026-10-04 | mini_calc,`ctl` / `ctl-heredoc` / `native-inc` / `xml` 各 1 次(ASAN) | 4/4 pass | + +## 实验 C — 真实模型流上的逐字节截断 + +```bash +make check # 关注 tests/test_replay_real +``` + +数据:`tests/fixtures/sse/deepseek-*/NNNN.sse`,是 `CST_RECORD_SSE` 录下的 DeepSeek 原始 SSE 流(同一任务分别用三种协议)。 +测试把每条助手消息还原成运行时看到的逻辑流,在**每一个字节**处截断:CTL 流喂给 CTL 解析器;原生流把各调用拼成 +`[{"name","arguments"},…]`,分别按 json-whole(整体可解析才执行,即 native-eos)和 json-object(每个对象可解析即执行,即 native-inc)计数。 + +全部前缀上 S1/S2/S3 成立(未完整调用不可执行;执行过的调用与完整流一致;可执行数单调)。可执行调用数随前缀比例变化: + +| 消息 | 协议 | 字节 | 调用 | 25% | 50% | 75% | 90% | 100% | 首个调用可执行于 | +|---|---|---|---|---|---|---|---|---|---| +| heredoc #0 | ctl-heredoc | 193 | 3 | 0 | 1 | 2 | 2 | 3 | 45% | +| heredoc #1 | ctl-heredoc | 223 | 2 | 0 | 0 | 0 | 1 | 2 | 85% | +| ctl #0 | ctl | 74 | 2 | 0 | 0 | 1 | 1 | 2 | 51% | +| native #0 | json-whole | 100 | 2 | 0 | 0 | 0 | 0 | 2 | 100% | +| native #0 | json-object | 100 | 2 | 0 | 0 | 1 | 1 | 2 | — | +| native #1 | json-whole | 108 | 2 | 0 | 0 | 0 | 0 | 2 | 100% | +| native #1 | json-object | 108 | 2 | 0 | 1 | 1 | 1 | 2 | — | + +读法:json-whole 在流结束前一个调用都不能执行;CTL 与 json-object 都是前缀单调的,确认点落在每个调用结束处。 +CTL 的区别在于 pending 有界(\(\le L_{\mathrm{line}}\)),json-object 的 pending 是整个参数对象。 +另外 `ctl` 目录里 #1–#4 是四次长度前缀写错的消息(三次 patch 字节数写大、一次 content 写小,见实验 D),解析器在这些消息的任何前缀上**一次也没有执行**。 + +`tests/test_sse_decode` 还验证:录制流按 1 字节和 4096 字节分块回放,解码出的文本、调用和参数完全相同(网络分块不影响确认结果)。 + +## 实验 D — 协议对照矩阵(M3) + +```bash +scripts/run_matrix.sh -p "native-eos native-inc ctl ctl-heredoc xml" \ + -m "deepseek:deepseek-flash" -f "mini_calc makefile_tab big_file test_fix_test" -n 3 -r +# → out/matrix-*/results.csv(每次运行一行)与 summary.md +``` + +2026-10-04,DeepSeek `deepseek-chat`,5 协议 × 4 夹具 × 3 次 = 60 次(`out/matrix-m3/`): + +| 协议 | n | 通过率 | 步数 | 墙钟 s | 模型时间 s | 工具时间 s | wait_ms | 契约错误/次 | 输入 token | 输出 token | +|---|---|---|---|---|---|---|---|---|---|---| +| native-eos | 12 | 100% | 4.2 | 5.38 | 5.11 | 0.24 | 30 | 0 | 9900 | 1058 | +| native-inc | 12 | 100% | 4.7 | 5.90 | 5.82 | 0.26 | 0 | 0 | 10615 | 1084 | +| ctl | 12 | 58% | 5.8 | 7.26 | 7.09 | 0.16 | 0 | 2.33 | 10574 | 1336 | +| **ctl-heredoc** | 12 | **100%** | **3.7** | **4.48** | 4.27 | 0.24 | 0 | **0** | **5728** | **756** | +| xml | 12 | 92% | 5.7 | 7.09 | 6.84 | 0.19 | 53 | 1.75 | 10468 | 1398 | + +`wait_ms` = 调用确认到开始执行的平均间隔。eager 协议恒为 0;流结束后执行的协议要等到整条消息结束。 + +契约错误分解(全部 12 次运行合计): + +| 协议 | 参数写法使用次数 | 契约错误 | +|---|---|---| +| ctl | 长度前缀 33、行内 73 | `unexpected_line`(数少了)20、`body_eof`(数多了)8 | +| ctl-heredoc | 定界 30、行内 29、长度前缀 0 | 0 | +| xml | — | 标签写坏(如 ``)21 | + +结论(可写入论文): + +1. **长度前缀对真实模型不可靠**。模型几乎从不数对多行参数的字节数(33 次长度前缀参数里 28 次契约错误), + ctl 只有 58% 通过,失败的运行都是连续契约错误后放弃。CST 的安全性仍然成立——错误帧从未被执行——但可用性差。 +2. **定界写法消除了计数负担**,保留同样的确认前沿与有界 pending:ctl-heredoc 100% 通过、0 契约错误, + 且步数、墙钟、token 都是五种协议里最少的(输入 token 比 native-eos 少 42%:不发 `tools[]` schema,参数也不需要 JSON 转义)。 +3. **eager 执行在这些任务上的时延收益很小**:确认到执行的等待从几十毫秒降到 0;墙钟差异主要来自步数和 token,而非执行时机。 + 原因是模型通常把耗时工具(make、测试)放在一条消息的最后一个调用,确认之后流几乎立即结束。 +4. **反馈让文本协议可恢复**:xml 在加入“标签写坏→契约错误→要求重发”之前为 58%(`out/matrix-m3-old-xml/`),之后为 92%(重复一组为 83%,`out/matrix-m3-xml-replicate/`)。 + +注意事项: + +- 执行在 SSE 回调内同步进行,执行期间客户端暂停读流,所以客户端测得的“流结束”包含了工具时间。 + `summary.md` 里的 `overlap_s` 因此是上界(native-inc 下恒等于工具时间),不能当作节省的时间引用。 +- 每格只有 3 次,模型输出有随机性;引用通过率时请同时给出 n。 ## 与主论文的衔接建议 - **附录 / 扩展节标题示例**:「Tool-loop CST:确认前沿到工具调用」 -- 粘贴:theory 表(theory_tool_cst.md)+ 实验 A 表 + 实验 B 一行案例。 +- 粘贴:theory 表(theory_tool_cst.md)+ 实验 A/C 表 + 实验 D 主表与结论 1–2。 - 明确声明:主 fan-out / \(\mathcal{A}=0\) 数字仍来自 Sashidown;本笔记只提供 **同构第二实例**。 ## 明确不写进定理的内容 - 商业 API 空回复、权限 UX、TUI 观感 - 「已达生产 coding agent」类宣称 +- 实验 D 的时延数字(受网络与服务端负载影响) diff --git a/docs/theory_tool_cst.md b/docs/theory_tool_cst.md index 626a7d1..4be190e 100644 --- a/docs/theory_tool_cst.md +++ b/docs/theory_tool_cst.md @@ -43,6 +43,29 @@ end\n 取 \(K = L_{\mathrm{line}}\)(行头)与实现常量 `CTL_MAX_ARG`(单参上限,契约拒绝更大值)。 +### 参数的三种写法 + +| 写法 | 语法 | 何时确认 | 代价 | +|---|---|---|---| +| 长度前缀 | `arg ` + 恰好 nbytes 字节 | 每个体字节到达即确认;`end\n` 确认整次调用 | 模型必须**精确数字节**(含换行、UTF-8 多字节) | +| 行内 | `arg `(值为行尾剩余部分) | 头行 `\n` 即确认 | 只能单行;值不能全是数字(否则视为长度前缀) | +| 定界(heredoc) | `arg < <` | 流结束后执行 | + +文本协议的结果帧: + +```text +result exit= \n +\n +end\n +``` + +作为下一条 user 消息发送(模型不得自己写 result 帧)。超过 `CST_TOOL_OUT_CAP` 的输出先按“头 60% + 尾 40%”截断再计算 nbytes,保证帧内字节数精确。 + +### eager 执行的不可逆语义 + +- 调用在确认时刻立刻执行(同步,在 SSE 回调内)。执行期间客户端暂停读流,但服务端仍在继续生成,字节堆在 socket 缓冲里,工具返回后再被消费——模型生成与工具执行因此重叠。多个调用严格按确认顺序执行。 +- CTL 模式下,**已确认调用之后**若模型继续写普通文本(例如“臆想”工具结果),运行时立即停止读流(客户端 stop,而非错误),只把截至最后一个确认调用的原始文本写入会话。这正是“确认前沿之外的字节不产生副作用”。 +- 若同一条消息里,已执行的调用之后出现契约错误:已执行的副作用保留并记录,错误帧之后的内容丢弃,并请模型重发。 +- 用户取消(Esc)在执行中的调用上表现为“进程组被终止(exit=130)”,仍然记录,不撤回。 + +### 实测检验的三条性质(实验 C) + +对真实录制流的每一个字节前缀: + +- **S1**:未完整的调用永远不可执行; +- **S2**:前缀上已执行的调用与完整流中的同一调用逐字节相同(执行永不被撤回或改写); +- **S3**:可执行调用数随前缀长度单调不减。 + ## 补丁确认 `apply_patch` 的 patch 文本作为 CTL 的一个 `arg`;应用侧再要求 unified diff 的每个 hunk 完整(`@@` 到下一块或 EOF)才写盘——整 hunk 确认,禁止半截应用。 + +实现上更进一步:一个 patch 可含多个文件(`---`/`+++` 头,`/dev/null` 表示新建/删除)。所有文件的所有 hunk 先在内存中定位并计算结果,**全部成功才写盘**;任一 hunk 定位失败或有歧义(同一上下文匹配多处且无行号),整个 patch 不产生任何磁盘副作用。hunk 优先在 `@@ -a,b` 给出的行号附近查找,行号不准时退化为搜索,并容忍行尾空白差异。 diff --git a/fixtures/ask_or_todo/TASK.md b/fixtures/ask_or_todo/TASK.md new file mode 100644 index 0000000..67f8683 --- /dev/null +++ b/fixtures/ask_or_todo/TASK.md @@ -0,0 +1,20 @@ +# Task: three small scripts, planned with a task list + +Create these files in the workspace root: + +1. `greet.sh` — prints `Hello, NAME!` where NAME is the first argument, or + `Hello, world!` when no argument is given. +2. `count.sh` — prints the number of lines in the file given as the first + argument (just the number). Exits with status 2 and prints a usage line to + stderr when no argument is given. +3. `SCRIPTS.md` — one short section per script explaining how to run it. + +Both scripts must be executable POSIX shell scripts (`#!/bin/sh`). + +Rules: +- Start by calling `todo_write` with one item per file, then keep the list + up to date (mark each item done as you finish it). Every item must be done + at the end. +- Nobody is available to answer questions in this run. If `ask_user` fails, + pick a sensible default and continue. +- Test both scripts with `run_shell` before you finish. diff --git a/fixtures/ask_or_todo/check.sh b/fixtures/ask_or_todo/check.sh new file mode 100755 index 0000000..fbbb97b --- /dev/null +++ b/fixtures/ask_or_todo/check.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Pass iff the scripts work and the agent kept a finished task list. +set -euo pipefail +cd "$(dirname "$0")" +fail() { echo "check: $*"; exit 1; } +[[ -x greet.sh && -x count.sh ]] || fail "scripts missing or not executable" +[[ "$(./greet.sh)" == "Hello, world!" ]] || fail "greet.sh without argument" +[[ "$(./greet.sh Ada)" == "Hello, Ada!" ]] || fail "greet.sh Ada" +[[ "$(./count.sh notes.txt | tr -d ' ')" == "3" ]] || fail "count.sh notes.txt" +set +e +./count.sh >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 2 ]] || fail "count.sh without argument should exit 2 (got $rc)" +[[ -s SCRIPTS.md ]] || fail "SCRIPTS.md missing" +grep -q greet.sh SCRIPTS.md && grep -q count.sh SCRIPTS.md || + fail "SCRIPTS.md does not mention both scripts" +# The session must show todo_write ending with every item done. +python3 - <<'PY' || fail "no finished todo_write list in the session" +import glob, json, re, sys +last = None +for path in glob.glob(".agent/*.jsonl") + glob.glob(".agent/sessions/*.jsonl"): + for line in open(path, encoding="utf-8"): + try: + m = json.loads(line) + except ValueError: + continue + c = m.get("content") or "" + r = re.search(r"todo list updated: (\d+)/(\d+) done", c) + if r: + last = (int(r.group(1)), int(r.group(2))) +sys.exit(0 if last and last[1] >= 3 and last[0] == last[1] else 1) +PY +echo "check: PASSED" diff --git a/fixtures/ask_or_todo/notes.txt b/fixtures/ask_or_todo/notes.txt new file mode 100644 index 0000000..ff6e6b1 --- /dev/null +++ b/fixtures/ask_or_todo/notes.txt @@ -0,0 +1,3 @@ +first +second +third diff --git a/fixtures/big_file/Makefile b/fixtures/big_file/Makefile new file mode 100644 index 0000000..a48c9ed --- /dev/null +++ b/fixtures/big_file/Makefile @@ -0,0 +1,5 @@ +.PHONY: check + +check: + @test -f lines.txt || { echo "lines.txt missing"; exit 1; } + @seq -f 'line %04g' 1 500 | cmp - lines.txt && echo "check: PASSED ($$(wc -c < lines.txt) bytes)" diff --git a/fixtures/big_file/TASK.md b/fixtures/big_file/TASK.md new file mode 100644 index 0000000..9528f90 --- /dev/null +++ b/fixtures/big_file/TASK.md @@ -0,0 +1,8 @@ +# 任务:写一个大于 4KB 的文件 + +请用 `write_file` 工具(不要用 shell 的 seq/printf/循环生成)一次性写出 `lines.txt`: + +- 共 500 行,第 i 行是 `line ` 加上 4 位补零的 i,例如第 1 行 `line 0001`,第 500 行 `line 0500`; +- 每行以换行结尾,文件总长 5000 字节。 + +然后运行 `make check` 验证;若失败,读输出修正后再验证。最后用一句话说明结果。 diff --git a/fixtures/big_file/check.sh b/fixtures/big_file/check.sh new file mode 100755 index 0000000..345e57b --- /dev/null +++ b/fixtures/big_file/check.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +make check diff --git a/fixtures/makefile_tab/TASK.md b/fixtures/makefile_tab/TASK.md new file mode 100644 index 0000000..d7546eb --- /dev/null +++ b/fixtures/makefile_tab/TASK.md @@ -0,0 +1,11 @@ +# 任务:为 hello 项目写 Makefile + +工作区里只有 `hello.c` 和 `tests/expect.txt`,还没有 Makefile。请: + +1. 新建 `Makefile`(注意:命令行必须用 TAB 缩进),包含以下目标: + - `all`(默认目标):用 `cc -std=c11 -Wall -Wextra -O2 -o hello hello.c` 编译出 `hello`; + - `test`:依赖 `hello`,运行 `./hello > tests/actual.txt`,再用 `diff -u tests/expect.txt tests/actual.txt` 比较; + - `clean`:删除 `hello` 和 `tests/actual.txt`; + - 把 `all test clean` 声明为 `.PHONY`。 +2. 运行 `make clean && make test`,确认通过。 +3. 用一句话说明结果。 diff --git a/fixtures/makefile_tab/check.sh b/fixtures/makefile_tab/check.sh new file mode 100755 index 0000000..5fb4999 --- /dev/null +++ b/fixtures/makefile_tab/check.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Pass iff the agent wrote a working Makefile (recipes need real TABs). +set -euo pipefail +cd "$(dirname "$0")" +test -f Makefile +grep -q "$(printf '^\t')" Makefile +make clean >/dev/null +make test +make clean >/dev/null +test ! -e hello diff --git a/fixtures/makefile_tab/hello.c b/fixtures/makefile_tab/hello.c new file mode 100644 index 0000000..c97c702 --- /dev/null +++ b/fixtures/makefile_tab/hello.c @@ -0,0 +1,7 @@ +#include + +int main(void) { + printf("hello, cst\n"); + printf("tab\there\n"); + return 0; +} diff --git a/fixtures/makefile_tab/tests/expect.txt b/fixtures/makefile_tab/tests/expect.txt new file mode 100644 index 0000000..d6afabc --- /dev/null +++ b/fixtures/makefile_tab/tests/expect.txt @@ -0,0 +1,2 @@ +hello, cst +tab here diff --git a/fixtures/mini_calc/check.sh b/fixtures/mini_calc/check.sh new file mode 100755 index 0000000..991186a --- /dev/null +++ b/fixtures/mini_calc/check.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Pass iff the task is done (used by scripts/e2e_fixture.sh). +set -euo pipefail +cd "$(dirname "$0")" +make test diff --git a/fixtures/multi_file_refactor/Makefile b/fixtures/multi_file_refactor/Makefile new file mode 100644 index 0000000..24f7851 --- /dev/null +++ b/fixtures/multi_file_refactor/Makefile @@ -0,0 +1,20 @@ +CC ?= gcc +CFLAGS ?= -std=c11 -Wall -Wextra -O2 + +OBJS = main.o order.o report.o + +.PHONY: all clean test + +all: shop + +shop: $(OBJS) + $(CC) $(CFLAGS) -o $@ $(OBJS) + +%.o: %.c order.h report.h + $(CC) $(CFLAGS) -c -o $@ $< + +test: shop + ./tests/run.sh + +clean: + rm -f shop $(OBJS) diff --git a/fixtures/multi_file_refactor/TASK.md b/fixtures/multi_file_refactor/TASK.md new file mode 100644 index 0000000..a179747 --- /dev/null +++ b/fixtures/multi_file_refactor/TASK.md @@ -0,0 +1,15 @@ +# Task: rename across a small C project + +`shop` is a tiny C program split over several files. Do the following, then stop: + +1. Rename the function `compute_total` to `order_total` everywhere: the + declaration, the definition, every call site and the comments. Nothing + named `compute_total` may remain anywhere in the project. +2. `report.c` applies the 10% discount a second time, so the printed total is + too low. Fix it so the discount is applied exactly once (`order_total` + already applies it). +3. Run `make test` and make sure it passes. +4. Summarize the change in two or three sentences. + +Find the call sites with the search tools instead of guessing. Edit only +what the task needs. diff --git a/fixtures/multi_file_refactor/check.sh b/fixtures/multi_file_refactor/check.sh new file mode 100755 index 0000000..c0bbcbb --- /dev/null +++ b/fixtures/multi_file_refactor/check.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Pass iff the rename is complete and the discount bug is fixed. +set -euo pipefail +cd "$(dirname "$0")" +if grep -rn --include='*.c' --include='*.h' compute_total . ; then + echo "check: compute_total still present" + exit 1 +fi +grep -q 'int order_total(const int \*prices, int n);' order.h +make -s clean >/dev/null +make -s test diff --git a/fixtures/multi_file_refactor/main.c b/fixtures/multi_file_refactor/main.c new file mode 100644 index 0000000..2b5b051 --- /dev/null +++ b/fixtures/multi_file_refactor/main.c @@ -0,0 +1,19 @@ +#include +#include + +#include "order.h" +#include "report.h" + +int main(int argc, char **argv) { + int prices[64]; + int n = 0; + for (int i = 1; i < argc && n < 64; i++) + prices[n++] = atoi(argv[i]); + if (n == 0) { + fprintf(stderr, "usage: shop PRICE_CENTS...\n"); + return 2; + } + print_report(stdout, prices, n); + /* exit status: 0 when compute_total is positive */ + return compute_total(prices, n) > 0 ? 0 : 1; +} diff --git a/fixtures/multi_file_refactor/order.c b/fixtures/multi_file_refactor/order.c new file mode 100644 index 0000000..42ebfbb --- /dev/null +++ b/fixtures/multi_file_refactor/order.c @@ -0,0 +1,17 @@ +#include "order.h" + +int compute_total(const int *prices, int n) { + int sum = 0; + for (int i = 0; i < n; i++) + sum += prices[i]; + if (n > 3) + sum -= sum / 10; /* bulk discount */ + return sum; +} + +int count_expensive(const int *prices, int n, int limit) { + int c = 0; + for (int i = 0; i < n; i++) + c += prices[i] > limit; + return c; +} diff --git a/fixtures/multi_file_refactor/order.h b/fixtures/multi_file_refactor/order.h new file mode 100644 index 0000000..168f30a --- /dev/null +++ b/fixtures/multi_file_refactor/order.h @@ -0,0 +1,11 @@ +#ifndef ORDER_H +#define ORDER_H + +/* Sum of prices in cents, with the 10% bulk discount applied when there + * are more than three items. */ +int compute_total(const int *prices, int n); + +/* Number of items that cost more than limit cents. */ +int count_expensive(const int *prices, int n, int limit); + +#endif diff --git a/fixtures/multi_file_refactor/report.c b/fixtures/multi_file_refactor/report.c new file mode 100644 index 0000000..bf74a65 --- /dev/null +++ b/fixtures/multi_file_refactor/report.c @@ -0,0 +1,12 @@ +#include "report.h" +#include "order.h" + +void print_report(FILE *out, const int *prices, int n) { + /* compute_total gives the discounted sum */ + int total = compute_total(prices, n); + if (n > 3) + total -= total / 10; + fprintf(out, "items: %d\n", n); + fprintf(out, "expensive: %d\n", count_expensive(prices, n, 1000)); + fprintf(out, "total: %d.%02d\n", total / 100, total % 100); +} diff --git a/fixtures/multi_file_refactor/report.h b/fixtures/multi_file_refactor/report.h new file mode 100644 index 0000000..ddef9ed --- /dev/null +++ b/fixtures/multi_file_refactor/report.h @@ -0,0 +1,8 @@ +#ifndef REPORT_H +#define REPORT_H + +#include + +void print_report(FILE *out, const int *prices, int n); + +#endif diff --git a/fixtures/multi_file_refactor/tests/run.sh b/fixtures/multi_file_refactor/tests/run.sh new file mode 100755 index 0000000..22ce60d --- /dev/null +++ b/fixtures/multi_file_refactor/tests/run.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Exit 0 only when shop prints the expected reports. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fail=0 + +expect() { + local want="$1" + shift + local got + got="$("$ROOT/shop" "$@")" + if [[ "$got" != "$want" ]]; then + printf 'FAIL: shop %s\n--- want\n%s\n--- got\n%s\n' "$*" "$want" "$got" + fail=1 + else + echo "ok: shop $*" + fi +} + +expect $'items: 2\nexpensive: 0\ntotal: 15.00' 500 1000 +expect $'items: 4\nexpensive: 2\ntotal: 36.00' 500 1500 2000 0 +expect $'items: 5\nexpensive: 0\ntotal: 45.00' 1000 1000 1000 1000 1000 + +if [[ "$fail" -ne 0 ]]; then + echo "tests: FAILED" + exit 1 +fi +echo "tests: PASSED" diff --git a/fixtures/snake_cpp/.baseline_green_ok b/fixtures/snake_cpp/.baseline_green_ok new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/snake_cpp/check.sh b/fixtures/snake_cpp/check.sh new file mode 100755 index 0000000..fe4ebf3 --- /dev/null +++ b/fixtures/snake_cpp/check.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +make snake +test -x snake diff --git a/fixtures/test_fix_test/Makefile b/fixtures/test_fix_test/Makefile new file mode 100644 index 0000000..1690d20 --- /dev/null +++ b/fixtures/test_fix_test/Makefile @@ -0,0 +1,14 @@ +CC ?= cc +CFLAGS ?= -std=c11 -Wall -Wextra -O2 + +.PHONY: test clean + +test_strutil: strutil.c strutil.h tests/test_strutil.c + $(CC) $(CFLAGS) -o $@ strutil.c tests/test_strutil.c + +test: test_strutil + @echo run >> .test_runs + ./test_strutil + +clean: + rm -f test_strutil diff --git a/fixtures/test_fix_test/TASK.md b/fixtures/test_fix_test/TASK.md new file mode 100644 index 0000000..6f18d48 --- /dev/null +++ b/fixtures/test_fix_test/TASK.md @@ -0,0 +1,6 @@ +# 任务:先跑测试,再修,再跑测试 + +1. 先运行 `make test`,看哪些用例失败(此时不要改代码)。 +2. 阅读 `strutil.c`,修复导致失败的 bug(不要修改 `tests/` 下的文件)。 +3. 再次运行 `make test`,确认全部通过。 +4. 用简短中文说明你修了什么。 diff --git a/fixtures/test_fix_test/check.sh b/fixtures/test_fix_test/check.sh new file mode 100755 index 0000000..4c918ea --- /dev/null +++ b/fixtures/test_fix_test/check.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pass iff tests pass now AND the agent really ran `make test` at least twice +# (before and after the fix; an over-eager duplicate filter would skip one). +set -euo pipefail +cd "$(dirname "$0")" +runs=$(wc -l < .test_runs 2>/dev/null || echo 0) +echo "agent test runs: $runs" +git_clean_tests=1 +grep -q 'check_vowels("Queue", 4);' tests/test_strutil.c || git_clean_tests=0 +[ "$git_clean_tests" = 1 ] || { echo "tests were modified"; exit 1; } +[ "$runs" -ge 2 ] || { echo "make test ran fewer than 2 times"; exit 1; } +make test diff --git a/fixtures/test_fix_test/strutil.c b/fixtures/test_fix_test/strutil.c new file mode 100644 index 0000000..9885839 --- /dev/null +++ b/fixtures/test_fix_test/strutil.c @@ -0,0 +1,23 @@ +#include "strutil.h" + +#include +#include + +void str_reverse(char *s) { + size_t n = strlen(s); + for (size_t i = 0; i < n / 2; i++) { + char t = s[i]; + s[i] = s[n - i]; + s[n - i] = t; + } +} + +int count_vowels(const char *s) { + int k = 0; + for (; *s; s++) { + char c = (char)tolower((unsigned char)*s); + if (c == 'a' || c == 'e' || c == 'i' || c == 'o') + k++; + } + return k; +} diff --git a/fixtures/test_fix_test/strutil.h b/fixtures/test_fix_test/strutil.h new file mode 100644 index 0000000..deaa5e4 --- /dev/null +++ b/fixtures/test_fix_test/strutil.h @@ -0,0 +1,7 @@ +#ifndef STRUTIL_H +#define STRUTIL_H + +void str_reverse(char *s); +int count_vowels(const char *s); + +#endif diff --git a/fixtures/test_fix_test/tests/test_strutil.c b/fixtures/test_fix_test/tests/test_strutil.c new file mode 100644 index 0000000..217111d --- /dev/null +++ b/fixtures/test_fix_test/tests/test_strutil.c @@ -0,0 +1,39 @@ +#include "../strutil.h" + +#include +#include + +static int fails; + +static void check_rev(const char *in, const char *want) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s", in); + str_reverse(buf); + if (strcmp(buf, want) != 0) { + printf("FAIL str_reverse(\"%s\") = \"%s\", want \"%s\"\n", in, buf, want); + fails++; + } +} + +static void check_vowels(const char *in, int want) { + int got = count_vowels(in); + if (got != want) { + printf("FAIL count_vowels(\"%s\") = %d, want %d\n", in, got, want); + fails++; + } +} + +int main(void) { + check_rev("abc", "cba"); + check_rev("ab", "ba"); + check_rev("", ""); + check_vowels("education", 5); + check_vowels("RHYTHM", 0); + check_vowels("Queue", 4); + if (fails) { + printf("%d test(s) failed\n", fails); + return 1; + } + printf("all tests passed\n"); + return 0; +} diff --git a/include/cst_agent.h b/include/cst_agent.h index 1010516..a0f8d48 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -11,7 +11,9 @@ extern "C" { #define CST_AGENT_MAX_MSG 256 #define CST_AGENT_MAX_CONTENT (512 * 1024) -#define CST_AGENT_CTX_WINDOW 40 /* max non-system msgs sent to LLM */ +#define CST_AGENT_CTX_WINDOW 80 /* max non-system msgs sent to LLM */ +#define CST_DEFAULT_MAX_STEPS 30 +#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024) typedef enum { CST_ROLE_SYSTEM = 0, @@ -20,12 +22,39 @@ typedef enum { CST_ROLE_TOOL } cst_role_t; +/* How tool calls travel between model and runtime. + * native-eos : OpenAI tools[]; execute after the stream ends (baseline). + * native-inc : OpenAI tools[]; execute as soon as one call's arguments are + * complete (next index started / finish_reason). + * ctl : CTL frames in plain text, length-prefixed args; execute on + * confirmed `end` while the stream is still running. + * ctl-heredoc: same, prompt teaches `arg < text; execute after the stream. */ +typedef enum { + CST_PROTO_NATIVE_EOS = 0, + CST_PROTO_NATIVE_INC, + CST_PROTO_CTL, + CST_PROTO_CTL_HEREDOC, + CST_PROTO_XML +} cst_protocol_t; + +const char *cst_protocol_name(cst_protocol_t p); +int cst_protocol_parse(const char *s, cst_protocol_t *out); +int cst_protocol_is_native(cst_protocol_t p); +int cst_protocol_is_ctl(cst_protocol_t p); +/* Text protocols carry calls/results in message text (no tools[]). */ +int cst_protocol_is_text(cst_protocol_t p); +/* Eager protocols execute a call during the stream, at confirmation time. */ +int cst_protocol_is_eager(cst_protocol_t p); + typedef struct { cst_role_t role; char *content; - char tool_call_id[CTL_MAX_ID]; /* for tool results */ + char tool_call_id[CTL_MAX_ID]; /* tool results (role=tool, or text-protocol + result frames sent as role=user) */ char tool_name[CTL_MAX_NAME]; char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */ + char *meta_json; /* optional JSON object (kind, protocol, ...) */ } cst_message_t; typedef struct { @@ -34,10 +63,18 @@ typedef struct { char api_base[512]; char api_key[512]; char model[128]; + char provider[32]; + char effort[16]; /* low | medium | high | max */ + cst_protocol_t protocol; + int max_steps; /* model calls per user turn */ + size_t tool_out_cap; /* bytes of one tool output sent to the model */ int headless; int confirm_shell; /* confirm run_shell */ - int confirm_write; /* confirm write_file / apply_patch */ - int auto_approve; /* headless / CST_AUTO_APPROVE=1 */ + int confirm_write; /* confirm file-changing tools */ + int confirm_net; /* confirm web_fetch */ + int auto_approve; /* headless / CST_AUTO_APPROVE=1 / --yolo */ + int tool_profile; /* cst_tool_profile_t (CST_TOOLS) */ + unsigned long long tools_off; /* bit i = registry tool i disabled */ } cst_agent_config_t; typedef struct cst_session cst_session_t; @@ -56,6 +93,10 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i); const char *cst_session_path(const cst_session_t *s); int cst_session_clear(cst_session_t *s, int keep_system); int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n); +/* Readers on another thread must hold the lock while using pointers returned + * by cst_session_get (append may reallocate). Append locks internally. */ +void cst_session_lock(cst_session_t *s); +void cst_session_unlock(cst_session_t *s); /* --- workspace .agent/sessions store (OpenCode-like) --- */ int cst_agent_store_ensure(const char *workspace); @@ -70,6 +111,18 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out, size_t path_sz, char *id_out, size_t id_sz); int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids); +typedef struct { + char id[128]; + long long mtime; + int messages; + char title[128]; /* set via /rename; empty when none */ + char first_user[128]; /* first user message, one line */ +} cst_session_info_t; +int cst_agent_store_info(const char *workspace, const char *id, + cst_session_info_t *info); +int cst_agent_store_title_set(const char *workspace, const char *id, + const char *title); +int cst_agent_store_delete(const char *workspace, const char *id); /* --- tools --- */ typedef struct { @@ -80,6 +133,12 @@ typedef struct { void cst_tool_result_free(cst_tool_result_t *r); +int cst_tool_known(const char *name); +int cst_tool_is_shell(const char *name); +int cst_tool_is_write(const char *name); +/* OpenAI tools[] array for every tool (cJSON*, caller frees). */ +void *cst_tools_schema(void); + int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r); int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call, @@ -91,30 +150,72 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call, int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r); -/* Build ctl_tool_call from native OpenAI tool_call fields. */ +/* Build ctl_tool_call from native OpenAI tool_call fields. Returns -1 when + * args_json is not a JSON object (call still gets id/name). */ int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id, const char *name, const char *args_json); +void cst_tool_call_clear(ctl_tool_call_t *c); + +/* Apply unified-diff hunks (text after the ---/+++ header) to one buffer. + * All hunks must be complete and locate unambiguously, else nothing changes. + * On success *out is malloc'd. */ +int cst_patch_apply_buffer(const char *orig, size_t olen, const char *hunks, + size_t hlen, char **out, size_t *out_len, + int *nhunks, char *err, size_t err_sz); /* --- LLM --- */ typedef void (*cst_llm_on_delta_fn)(const char *data, size_t len, void *ud); +/* args_json == NULL means the arguments were not valid JSON / too large. */ typedef void (*cst_llm_on_native_tool_fn)(const char *id, const char *name, const char *args_json, void *ud); +typedef void (*cst_llm_on_native_open_fn)(int index, const char *id, + const char *name, void *ud); + +typedef struct { + long http_status; + char finish_reason[32]; + long prompt_tokens; + long completion_tokens; + long cached_tokens; + double t_first_byte_ms; /* relative to request start; -1 if none */ + double t_end_ms; + int client_stopped; /* stream ended by stop_flag (not an error) */ + int retries; + char error[512]; /* in-stream or HTTP error message */ +} cst_llm_stats_t; typedef struct { const char *api_base; const char *api_key; const char *model; + const char *reasoning_effort; /* NULL = not sent */ const cst_message_t *msgs; int nmsgs; cst_llm_on_delta_fn on_delta; - cst_llm_on_native_tool_fn on_native_tool; /* fired once per completed call */ + /* Fired once per call, as soon as that call's arguments are complete. */ + cst_llm_on_native_tool_fn on_native_tool; + cst_llm_on_native_open_fn on_native_open; void *userdata; int enable_tools; /* send tools[] schema */ + const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */ int tool_choice_none; /* "tool_choice":"none" */ - volatile int *cancel_flag; /* non-zero → abort curl */ + size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */ + volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */ + volatile int *stop_flag; /* non-zero → stop reading, not an error */ + cst_llm_stats_t *stats; /* optional */ } cst_llm_request_t; int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz); +/* JSON request body (malloc'd) exactly as sent on the wire. */ +char *cst_llm_build_body(const cst_llm_request_t *req); +/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */ +void cst_llm_trace_reset(void); + +/* on_permission answers */ +#define CST_PERM_DENY 0 +#define CST_PERM_ONCE 1 +#define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */ +#define CST_PERM_ALWAYS_PREFIX 3 /* run_shell: allow this command prefix */ /* Live UI / observability hooks for a turn */ typedef struct { @@ -123,8 +224,20 @@ typedef struct { void (*on_tool_event)(const char *phase, const ctl_tool_call_t *call, const char *detail, void *ud); void (*on_think_delta)(const char *text, size_t len, void *ud); - /* Return 1=allow, 0=deny. NULL → use config auto_approve / defaults. */ - int (*on_permission)(const char *tool_name, const char *detail, void *ud); + /* Return a CST_PERM_* answer. NULL → deny unless auto_approve. preview is + * the diff a write call would make, the full command for run_shell, or + * NULL. */ + int (*on_permission)(const char *tool_name, const char *detail, + const char *preview, void *ud); + /* ask_user tool. Fill ans and return 0, or -1 when dismissed. NULL means + * nobody can answer (headless). */ + int (*on_ask_user)(const char *question, const char *options, char *ans, + size_t ans_sz, void *ud); + /* todo_write replaced the task list (one item per line). */ + void (*on_todo)(const char *text, void *ud); + /* Full tool output, fired right after execution (before it is persisted). */ + void (*on_tool_result)(const ctl_tool_call_t *call, const char *out, + size_t len, int exit_code, void *ud); void *userdata; } cst_agent_hooks_t; @@ -143,14 +256,58 @@ void cst_agent_request_cancel(cst_agent_t *a); int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz); int cst_agent_switch_session(cst_agent_t *a, const char *id); int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz); +/* Bare id of the current session (file name without .jsonl). */ +void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz); +/* Latest todo_write list of this session, or NULL. */ +const char *cst_agent_todo(const cst_agent_t *a); +/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */ +const char *cst_agent_notes(const cst_agent_t *a); +void cst_agent_reload_notes(cst_agent_t *a); +/* Roll back the file changes of the newest undoable turn. */ +int cst_agent_undo(cst_agent_t *a, char *report, size_t report_sz); +int cst_agent_undo_available(const cst_agent_t *a); +/* Persistent permission rules (see cst_perms.h). */ +struct cst_perms *cst_agent_perms(cst_agent_t *a); +/* /retry: remove the last user prompt and its replies; caller frees. */ +char *cst_agent_rewind_last_turn(cst_agent_t *a); +/* Last prompt_tokens reported by the model (0 if unknown). */ +long cst_agent_last_prompt_tokens(const cst_agent_t *a); const char *cst_agent_system_prompt(void); +const char *cst_agent_system_prompt_for(cst_protocol_t p); +/* Prompt for this config's tool set and protocol, plus optional project + * instructions (AGENTS.md). malloc'd. */ +char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p, + const char *project_notes); -/* Credentials: zenity + ~/.config/cst-agent/credentials (0600) */ -int cst_creds_load_api_key(char *out, size_t out_sz); -int cst_creds_save_api_key(const char *key); -int cst_creds_prompt_api_key(char *out, size_t out_sz); -int cst_creds_ensure_api_key(char *out, size_t out_sz); +/* Per-provider API keys in /credentials (mode 0600), one + * ".api_key=..." line each. */ +int cst_creds_load(const char *provider, char *out, size_t out_sz); +int cst_creds_save(const char *provider, const char *key); +/* Saved key, then the provider's env variable. Returns 0 when found; + * *from (optional) gets "saved" or the variable name. */ +int cst_creds_resolve(const char *provider, char *out, size_t out_sz, + const char **from); +/* Hidden console prompt (no echo). */ +int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); + +/* Last used provider, model per provider and effort, in + * /settings.json. */ +#define CST_SETTINGS_MAX_MODELS 16 +typedef struct { + char provider[32]; + char effort[16]; + int nmodels; + struct { + char provider[32]; + char model[128]; + } models[CST_SETTINGS_MAX_MODELS]; +} cst_settings_t; +int cst_settings_load(cst_settings_t *s); +int cst_settings_save(const cst_settings_t *s); +const char *cst_settings_model(const cst_settings_t *s, const char *provider); +/* Record cfg's provider, model and effort as the last used ones. */ +int cst_settings_remember(const cst_agent_config_t *cfg); /* JSON baseline parser for experiments (buffer until valid object). */ typedef struct { @@ -161,6 +318,29 @@ typedef struct { int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out); +/* Provider presets (base URL + default model). */ +typedef struct { + const char *name; + const char *api_base; + const char *model; + const char *key_env; /* provider-specific key variable, e.g. DEEPSEEK_API_KEY */ +} cst_provider_t; + +const cst_provider_t *cst_provider_find(const char *name); +const cst_provider_t *cst_provider_default(void); +int cst_provider_count(void); +const cst_provider_t *cst_provider_at(int i); +/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */ +long cst_model_context_tokens(const char *model); + +/* Reasoning effort: low | medium | high | max. */ +#define CST_DEFAULT_EFFORT "medium" +int cst_effort_valid(const char *effort); +/* Value for "reasoning_effort", or NULL when this provider/model does not + * take the parameter. */ +const char *cst_effort_wire(const char *provider, const char *model, + const char *effort); + /* Built-in / CST_MODELS model picker list. Writes up to max_out names. * Always includes `current` if non-empty and not already listed. */ int cst_models_list(const char *current, char out[][128], int max_out); diff --git a/include/cst_metrics.h b/include/cst_metrics.h new file mode 100644 index 0000000..f8f4cee --- /dev/null +++ b/include/cst_metrics.h @@ -0,0 +1,22 @@ +#ifndef CST_METRICS_H +#define CST_METRICS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Append-only experiment log: /.agent/metrics/.jsonl + * (override with CST_METRICS_PATH, disable with CST_METRICS=0). */ +typedef struct cst_metrics cst_metrics_t; + +cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path); +void cst_metrics_close(cst_metrics_t *m); +/* Writes one line and frees obj (a cJSON*). NULL-safe. */ +void cst_metrics_emit(cst_metrics_t *m, void *obj); +const char *cst_metrics_path(const cst_metrics_t *m); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_perms.h b/include/cst_perms.h new file mode 100644 index 0000000..6fb3e50 --- /dev/null +++ b/include/cst_perms.h @@ -0,0 +1,31 @@ +#ifndef CST_PERMS_H +#define CST_PERMS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct cst_perms cst_perms_t; + +/* Loads /.agent/permissions.json (missing file = no rules). */ +cst_perms_t *cst_perms_open(const char *workspace); +void cst_perms_close(cst_perms_t *p); + +int cst_perms_tool_allowed(const cst_perms_t *p, const char *tool); +/* Prefix rule match; commands with ; & | ` $ < > ( ) never match. */ +int cst_perms_shell_allowed(const cst_perms_t *p, const char *cmd); +/* Add a rule and save. */ +int cst_perms_allow_tool(cst_perms_t *p, const char *tool); +int cst_perms_allow_prefix(cst_perms_t *p, const char *prefix); +int cst_perms_reset(cst_perms_t *p); +char *cst_perms_describe(const cst_perms_t *p); +/* Reasonable prefix rule for a command ("" when it should not have one). */ +void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_tools.h b/include/cst_tools.h new file mode 100644 index 0000000..3d0f0b6 --- /dev/null +++ b/include/cst_tools.h @@ -0,0 +1,107 @@ +#ifndef CST_TOOLS_H +#define CST_TOOLS_H + +#include "cst_agent.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CST_TOOL_MAX_PARAMS 6 +#define CST_TOOL_MAX_TARGETS 16 + +/* Up to max workspace-relative regular files (ignores .git, node_modules, + * root .gitignore ...). Caller frees each entry and the array. */ +int cst_workspace_files(const char *ws, char ***out, int max); + +/* What a call can touch; drives confirmation, undo and duplicate-read logic. */ +typedef enum { + CST_TP_READ = 0, /* reads the workspace */ + CST_TP_WRITE, /* changes files (snapshotted for /undo) */ + CST_TP_SHELL, /* arbitrary commands */ + CST_TP_NET, /* network access */ + CST_TP_ASK, /* talks to the user */ + CST_TP_META /* agent bookkeeping (todo list) */ +} cst_tool_perm_t; + +typedef struct { + const char *name; + const char *type; /* "string" | "integer" | "boolean" */ + int required; + const char *desc; +} cst_tool_param_t; + +typedef struct cst_tool_ctx { + const char *workspace_root; + /* ask_user: fills ans; returns 0, or -1 when nobody can answer. */ + int (*ask)(const char *question, const char *options, char *ans, + size_t ans_sz, void *ud); + void *ask_ud; + /* todo_write: the list was replaced (text form, one item per line). */ + void (*set_todo)(const char *text, void *ud); + void *todo_ud; +} cst_tool_ctx_t; + +typedef int (*cst_tool_run_fn)(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *call, + cst_tool_result_t *r); +/* Write tools: compute the diff a call would produce without touching disk. + * Returns 0 and a malloc'd text, or -1 with an error text in *out. */ +typedef int (*cst_tool_preview_fn)(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *call, char **out); + +typedef struct { + const char *name; + const char *desc; + /* `--tools minimal` schema text (the original wording; required params + * only), so paper runs keep sending byte-identical requests. */ + const char *min_desc; + cst_tool_param_t params[CST_TOOL_MAX_PARAMS]; + cst_tool_perm_t perm; + int minimal; /* member of `--tools minimal` (the original four) */ + cst_tool_run_fn run; + cst_tool_preview_fn preview; +} cst_tool_def_t; + +typedef enum { CST_TOOLS_DEFAULT = 0, CST_TOOLS_MINIMAL } cst_tool_profile_t; + +int cst_tool_profile_parse(const char *s, int *out); +const char *cst_tool_profile_name(int p); + +int cst_tool_count(void); +const cst_tool_def_t *cst_tool_at(int i); +const cst_tool_def_t *cst_tool_find(const char *name); +int cst_tool_index(const char *name); + +/* Enabled for this config (profile + /tools switches). cfg may be NULL. */ +int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t); +/* OpenAI tools[] for the active set (cJSON*, caller frees). */ +void *cst_tools_schema_cfg(const cst_agent_config_t *cfg); +/* "Tools:\n- name(a, b?): ...\n" for the active set (malloc'd). */ +char *cst_tools_prompt(const cst_agent_config_t *cfg); +/* Comma-separated active tool names (malloc'd). */ +char *cst_tools_names(const cst_agent_config_t *cfg); + +/* Missing required args / bad integers. 0 = ok. */ +int cst_tool_validate(const cst_tool_def_t *t, const ctl_tool_call_t *call, + char *err, size_t err_sz); +int cst_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r); +int cst_tool_preview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + char **out); + +/* Workspace-relative files a write call may change (for /undo snapshots). + * Returns the count written into paths. */ +int cst_tool_targets(const ctl_tool_call_t *call, char paths[][1024], int max); + +/* Unified diff (3 lines of context, GNU `diff -u` layout) with + * `--- a/