Merge remote-tracking branch 'origin/master' into feature/save-model-between-chats
This commit is contained in:
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+61
@@ -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"
|
||||
Executable
+30
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user