Merge remote-tracking branch 'origin/master' into feature/save-model-between-chats

This commit is contained in:
2026-08-12 15:41:23 -05:00
10 changed files with 219 additions and 28 deletions
+3
View File
@@ -2,6 +2,9 @@
#include <string>
#include <expected>
#include <functional>
#include <atomic>
extern std::atomic<bool> ctrlc_request;
// Trim whitespace from both ends of a string
std::string trim(const std::string& s);
+2 -20
View File
@@ -18,26 +18,8 @@ std::expected<std::string, std::string> run_editor(HarnessState state, bool full
print_history(history_str, j, false);
initial_content << history_str.str();
} else {
// Find the last AI response
const Message* last_ai_msg = nullptr;
for (int i = static_cast<int>(state.history.size()) - 1; i >= 0; --i) {
if (state.history[i].role == "assistant") {
last_ai_msg = &state.history[i];
break;
}
}
if (last_ai_msg) {
initial_content << "[" << last_ai_msg->role << "] ";
if (!last_ai_msg->reasoning.empty()) {
initial_content << "[Thinking: " << last_ai_msg->reasoning << "] ";
}
initial_content << last_ai_msg->content << "\n\n";
} else {
// Fallback to last message if no assistant message found
const auto& last_msg = state.history.back();
initial_content << "[" << last_msg.role << "] " << last_msg.content << "\n\n";
}
const auto& last_msg = state.history.back();
initial_content << "[" << last_msg.role << "] " << last_msg.content << "\n\n";
}
}
+27
View File
@@ -2,6 +2,7 @@
#include "json_types.hpp"
#include "base64_util.hpp"
#include <iostream>
#include "utility.hpp"
Harness::Harness(std::unique_ptr<Client> client, const ToolRegistry& registry,
HarnessConfig config)
@@ -80,11 +81,31 @@ std::expected<std::string, std::string> Harness::chat(const Message& user_messag
push_message(user_message);
for (size_t i = 0; i < config_.max_tool_iterations; ++i) {
if (ctrlc_request)
{
// Cancellation (Ctrl+C): return empty string to indicate the chat was
// interrupted. The user message has already been pushed to history, so
// the conversation state will be saved to the chat file. This allows
// the user to resume the conversation from this point.
//
// Note: We return an empty string (success) rather than std::unexpected
// because cancellation is not an error condition - it's a deliberate
// user action to stop processing. Using std::unexpected would prevent
// the harness state from being saved, losing the user's input.
return "";
}
Message response;
bool done = false;
size_t retries = 20;
while (!done)
{
if (ctrlc_request)
// Cancellation during API call: same semantics as above - return
// empty string to preserve conversation state in the chat file.
return "";
retries--;
if (retries == 0)
{
@@ -140,6 +161,12 @@ std::expected<std::string, std::string> Harness::chat(const Message& user_messag
tool_result.content = result;
push_message(tool_result);
}
if (ctrlc_request)
// Cancellation after tool execution: same semantics as above -
// return empty string to preserve conversation state in the chat file.
return "";
continue;
}
+43
View File
@@ -29,6 +29,47 @@
#include <filesystem>
#include <random>
#include <iomanip>
#ifndef _WIN32
#include <signal.h>
#else
#include <windows.h>
#endif
std::atomic<bool> ctrlc_request;
void cancel_request(void)
{
if (ctrlc_request)
{
exit(1); // Already asked to quit once, lets immediately die
}
ctrlc_request = true;
}
#if defined(_WIN32)
BOOL WINAPI console_handler(DWORD ctrl_type) {
if (ctrl_type == CTRL_C_EVENT) {
cancel_request();
return TRUE;
}
return FALSE;
}
void setup_signal_handler() {
SetConsoleCtrlHandler(console_handler, TRUE);
}
#else
void signalHandler(int signal) {
if (signal == SIGINT) {
cancel_request();
}
}
void setup_signal_handler() {
signal(SIGINT, signalHandler);
}
#endif
static const std::vector<AgentProfile> profiles = {
#if !defined(_WIN32)
@@ -97,6 +138,8 @@ static HarnessState read_harness_state_or_exit(const char* file_path) {
}
int main(int argc, char** argv) {
setup_signal_handler();
HarnessConfig harnessConfig = HarnessConfig{
.max_tool_iterations = 1000,
.verbose = true,
+23
View File
@@ -4,6 +4,7 @@
#include <string>
#include <iostream>
#include <expected>
#include "utility.hpp"
static int ollama_clients = 0;
@@ -38,6 +39,26 @@ size_t OllamaClient::write_cb(char* ptr, size_t size, size_t nmemb, void* userda
return size * nmemb;
}
static int xferinfo_cb(void* cookie,
curl_off_t dltotal,
curl_off_t dlnow,
curl_off_t ultotal,
curl_off_t ulnow)
{
(void)cookie;
(void)dltotal;
(void)dlnow;
(void)ultotal;
(void)ulnow;
if (ctrlc_request)
{
return 1; // Cancel
}
return 0; // Continue transfer
}
std::expected<std::string, std::string> OllamaClient::http_request(const std::string& full_url,
const std::string& method,
const std::string& body,
@@ -71,6 +92,8 @@ std::expected<std::string, std::string> OllamaClient::http_request(const std::st
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_buf);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+2
View File
@@ -19,6 +19,8 @@ add_dr_test(test_tool_read_file)
add_dr_test(test_tool_list_files)
add_dr_test(test_tool_grep)
add_dr_test(test_tool_shell)
add_dr_test(test_tool_shell_ctrlc)
add_dr_test(test_ctrlc_after_tool)
add_dr_test(test_tool_read_multi_modal)
# Add feature validation tests
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# test_ctrlc_after_tool.sh - Test ctrlc (SIGINT) handling between tool calls
# Create mock responses: two tool calls followed by a final response
# First tool sleeps 1 second, second tool is instant
# We send SIGINT during the first tool's execution (at 0.5s)
# The new ctrlc check after tool execution should catch it at 1s
tool_call run_dangerous_shell '{"command":"sleep 1","timeout_seconds":5}' >> mock_ctrlc_between_tools.txt
tool_call run_dangerous_shell '{"command":"echo second_tool","timeout_seconds":5}' >> mock_ctrlc_between_tools.txt
be_done 'Final response' >> mock_ctrlc_between_tools.txt
# Run drharness in background
"${DRHARNESS}" -F chat_ctrlc.json -n -m "Test ctrlc between tools" -t run_dangerous_shell -d < "mock_ctrlc_between_tools.txt" > /dev/null 2>&1 &
DRHARNESS_PID=$!
# Send SIGINT at 0.5s - during first tool's sleep
sleep 0.5
kill -SIGINT $DRHARNESS_PID 2>/dev/null || true
# Wait for drharness to complete (first tool finishes at 1s, then ctrlc check catches it)
wait $DRHARNESS_PID 2>/dev/null || true
# Verify the chat file exists and has the conversation state preserved
if [ ! -f chat_ctrlc.json ]; then
echo "FAIL: chat_ctrlc.json was not created"
exit 1
fi
# Check that we have exactly 3 history entries: user message, assistant tool call, tool result
# The second tool call should NOT have executed because ctrlc was caught after first tool
HISTORY_LENGTH=$(history_length chat_ctrlc.json)
if [ "$HISTORY_LENGTH" -ne 3 ]; then
echo "FAIL: Expected exactly 3 history entries (user, assistant, tool), got $HISTORY_LENGTH"
echo "This indicates the second tool call was not cancelled properly"
cat chat_ctrlc.json
exit 1
fi
# Verify user message is present
USER_ROLE=$(dr_role chat_ctrlc.json 0)
if [ "$USER_ROLE" != "user" ]; then
echo "FAIL: Expected user role at index 0, got $USER_ROLE"
exit 1
fi
# Verify assistant with tool call is present
ASSISTANT_ROLE=$(dr_role chat_ctrlc.json 1)
if [ "$ASSISTANT_ROLE" != "assistant" ]; then
echo "FAIL: Expected assistant role at index 1, got $ASSISTANT_ROLE"
exit 1
fi
# Verify tool result is present
TOOL_ROLE=$(dr_role chat_ctrlc.json 2)
if [ "$TOOL_ROLE" != "tool" ]; then
echo "FAIL: Expected tool role at index 2, got $TOOL_ROLE"
exit 1
fi
echo "PASS: ctrlc_after_tool"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# test_tool_shell_ctrlc.sh - Test ctrlc (SIGINT) handling in the shell tool
# Test 1: Verify that SIGINT during shell execution results in "[user requested halt of program]" message
tool_call run_dangerous_shell '{"command":"sleep 100","timeout_seconds":300}' >> mock_ctrlc.txt
be_done 'Done after interrupt' >> mock_ctrlc.txt
# Run drharness in background, then send SIGINT after a short delay
"${DRHARNESS}" -F chat_ctrlc.json -n -m "Run shell with ctrlc" -t run_dangerous_shell -d < "mock_ctrlc.txt" > /dev/null 2>&1 &
DRHARNESS_PID=$!
# Wait a moment for the shell command to start
sleep 1
# Send SIGINT (Ctrl+C) to the drharness process
kill -SIGINT $DRHARNESS_PID
# Wait for drharness to complete
wait $DRHARNESS_PID
# Check that the tool result contains the ctrlc message
TOOL_RESULT=$(dr_result chat_ctrlc.json 2)
if ! echo "${TOOL_RESULT}" | grep -q "\[user requested halt of program\]"; then
echo "FAIL: Expected '[user requested halt of program]' in tool output"
echo "Got: ${TOOL_RESULT}"
exit 1
fi
echo "PASS: tool_shell_ctrlc"
+18 -6
View File
@@ -1,5 +1,6 @@
#include "grep_tool.hpp"
#include "json_types.hpp"
#include "utility.hpp"
#include <filesystem>
#include <fstream>
#include <regex>
@@ -63,11 +64,11 @@ Tool make_grep_tool() {
std::string output;
size_t displayed_matches = 0;
auto process_file = [&](const std::string& file_path) -> void {
if (!is_text_file(file_path)) return;
auto process_file = [&](const std::string& file_path) -> bool {
if (!is_text_file(file_path)) return false;
std::ifstream file(file_path);
if (!file.is_open()) return;
if (!file.is_open()) return false;
std::vector<std::string> lines;
std::string line;
@@ -77,12 +78,15 @@ Tool make_grep_tool() {
int last_printed_line = -1;
for (size_t i = 0; i < lines.size(); ++i) {
if (ctrlc_request) {
return true;
}
std::smatch match;
if (std::regex_search(lines[i], match, re)) {
if (displayed_matches >= params.max_matches)
{
return;
return false;
}
displayed_matches++;
@@ -102,6 +106,7 @@ Tool make_grep_tool() {
output += "=== " + file_path + " ===\n";
}
}
return false;
};
std::error_code ec;
@@ -109,7 +114,9 @@ Tool make_grep_tool() {
if (ec) {
output += "Error checking path: " + ec.message() + "\n";
} else {
process_file(params.path);
if (process_file(params.path)) {
return "[user requested halt of program]\n" + output;
}
}
} else if (std::filesystem::is_directory(params.path, ec)) {
if (ec) {
@@ -121,9 +128,14 @@ Tool make_grep_tool() {
} else {
auto end = std::filesystem::recursive_directory_iterator();
while (it != end) {
if (ctrlc_request) {
return "[user requested halt of program]\n" + output;
}
if (std::filesystem::is_regular_file(it->path(), ec)) {
if (!ec) {
process_file(it->path().string());
if (process_file(it->path().string())) {
return "[user requested halt of program]\n" + output;
}
}
}
it.increment(ec);
+10 -2
View File
@@ -10,6 +10,7 @@
#include <unistd.h>
#include <signal.h>
#include <cstring>
#include "utility.hpp"
Tool make_shell_tool(bool safe) {
std::string tool_name = safe ? "run_shell" : "run_dangerous_shell";
@@ -96,8 +97,10 @@ Tool make_shell_tool(bool safe) {
int res = poll(&pfd, 1, 1000);
if (res == 0) {
if (std::chrono::steady_clock::now() - start_time
< std::chrono::seconds(params.timeout_seconds))
auto diff = std::chrono::steady_clock::now() - start_time;
if (diff < std::chrono::seconds(params.timeout_seconds) &&
!ctrlc_request)
{
continue; // Not timed out yet
}
@@ -146,6 +149,11 @@ Tool make_shell_tool(bool safe) {
int status;
waitpid(pid, &status, 0);
if (ctrlc_request)
{
return "[user requested halt of program]\n" + output;
}
if (timed_out) {
return "[timed out after " + std::to_string(params.timeout_seconds) + "s]\n" + output;
}