diff --git a/CMakeLists.txt b/CMakeLists.txt index 9415e48..123a775 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") endif() add_library(drharness_lib + src/encoding.cpp src/tool_registry.cpp src/ollama_client.cpp src/harness.cpp diff --git a/README.md b/README.md index 4c43ddf..940070f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ configuration. * `-s `: Manually constrain the harness to a specific subset of skills (can be specified multiple times) * `-i `: Image file to attach to the prompt (can be specified multiple times) * `-m `: The user prompt to send to the agent. +* `-P `: Read the prompt from a file instead of the command line. * `-M `: Override the Ollama model (e.g., `mistral`, `llama3`). ## ✍️ Editor Integration diff --git a/include/encoding.hpp b/include/encoding.hpp new file mode 100644 index 0000000..d6f36c1 --- /dev/null +++ b/include/encoding.hpp @@ -0,0 +1,5 @@ +#pragma once +#include + +char const* validate_utf8(char const* s); +std::string ensure_utf8(char const* s); diff --git a/src/encoding.cpp b/src/encoding.cpp new file mode 100644 index 0000000..b87a16e --- /dev/null +++ b/src/encoding.cpp @@ -0,0 +1,99 @@ +#include "encoding.hpp" + +char const* validate_utf8(char const* s) +{ + auto b = reinterpret_cast(s); + + unsigned char c0 = b[0]; + + // ASCII + if (c0 < 0x80) + return s + 1; + + // Continuation byte or invalid lead byte + if (c0 < 0xC2) + return nullptr; + + // 2-byte sequence + if (c0 <= 0xDF) + { + unsigned char c1 = b[1]; + if (c1 == '\0' || (c1 & 0xC0) != 0x80) + return nullptr; + return s + 2; + } + + // 3-byte sequence + if (c0 <= 0xEF) + { + unsigned char c1 = b[1]; + unsigned char c2 = b[2]; + + if (c1 == '\0' || c2 == '\0') + return nullptr; + + if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80) + return nullptr; + + // Overlong + if (c0 == 0xE0 && c1 < 0xA0) + return nullptr; + + // Surrogates + if (c0 == 0xED && c1 >= 0xA0) + return nullptr; + + return s + 3; + } + + // 4-byte sequence + if (c0 <= 0xF4) + { + unsigned char c1 = b[1]; + unsigned char c2 = b[2]; + unsigned char c3 = b[3]; + + if (c1 == '\0' || c2 == '\0' || c3 == '\0') + return nullptr; + + if ((c1 & 0xC0) != 0x80 || + (c2 & 0xC0) != 0x80 || + (c3 & 0xC0) != 0x80) + return nullptr; + + // Overlong + if (c0 == 0xF0 && c1 < 0x90) + return nullptr; + + // > U+10FFFF + if (c0 == 0xF4 && c1 >= 0x90) + return nullptr; + + return s + 4; + } + + return nullptr; +} + +std::string ensure_utf8(char const* s) +{ + std::string ret = ""; + + while (*s) + { + char const* next = validate_utf8(s); + if (next == 0) + { + ret += (0xc2+(((unsigned char)*s)>0xbf)); + ret += ((((unsigned char)*s)&0x3f))+0x80; + s++; + continue; + } + + ret += std::string(s, next); + + s = next; + } + + return ret; +} diff --git a/src/main.cpp b/src/main.cpp index 603edf0..9303da8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -157,6 +157,7 @@ int main(int argc, char** argv) { char const* file_to_open = nullptr; std::string auto_temp_file; // Path to auto-generated temp file (if any) char const* prompt = nullptr; + char const* prompt_file = nullptr; bool start_new = false; bool list_models = false; char const* print_file = nullptr; @@ -195,6 +196,11 @@ int main(int argc, char** argv) { consume_arg(); prompt = consume_arg(); } + else if (argv[0] == std::string("-P")) + { + consume_arg(); + prompt_file = consume_arg(); + } else if (argv[0] == std::string("-M")) { consume_arg(); @@ -335,6 +341,7 @@ int main(int argc, char** argv) { << " -i Image file to attach (can be specified multiple times)\n" << " -n Start a new conversation (auto-generates temp file if -F not specified)\n" << " -m Prompt message (required)\n" + << " -P Read prompt from file\n" << " -M Ollama model name\n" << " -f Open editor with full history (requires -F)\n" << " -b Edit history (requires -F)\n" @@ -424,7 +431,7 @@ int main(int argc, char** argv) { // Build list of discovered paths for debug output std::vector discovered_paths; for (const auto& skill : all_skills) { - discovered_paths.push_back(std::filesystem::path(skill.directory) / "SKILL.md"); + discovered_paths.push_back((std::filesystem::path(skill.directory) / "SKILL.md").string()); } // Build enabled skills list based on filters @@ -648,12 +655,19 @@ int main(int argc, char** argv) { harness = Harness(create_client(use_debug), master_registery, state_data); } - std::cerr << "drharness | model: " << cfg.model + std::cerr << "drharness v0.3 | model: " << cfg.model << " | " << cfg.base_url << "\n\n"; std::string final_prompt; - if (prompt) { + if (prompt_file) { + std::ifstream pf(prompt_file); + if (!pf.is_open()) { + std::cerr << "Error: Cannot open prompt file " << prompt_file << "\n"; + return 1; + } + final_prompt = std::string((std::istreambuf_iterator(pf)), std::istreambuf_iterator()); + } else if (prompt) { final_prompt = prompt; } else { final_prompt = expect_or_exit(run_editor(harness.serialize(), full_history)); diff --git a/src/ollama_client.cpp b/src/ollama_client.cpp index 8187f5f..1e2761b 100644 --- a/src/ollama_client.cpp +++ b/src/ollama_client.cpp @@ -132,6 +132,10 @@ std::expected OllamaClient::chat(const std::vector OllamaClient::chat(const std::vectorget_content_string()); total.reasoning_content += partial_message->reasoning_content; total.reasoning += partial_message->reasoning; - total.role += partial_message->role; - total.tool_call_id += partial_message->tool_call_id; + if (partial_message->role != "") + total.role = partial_message->role; + if (partial_message->tool_call_id != "") + total.tool_call_id = partial_message->tool_call_id; std::string to_write = partial_message->get_content_string() + partial_message->reasoning_content @@ -184,8 +190,10 @@ std::expected OllamaClient::chat(const std::vector ToolRegistry::register_tool(Tool tool) { if (tools_.count(tool.name)) @@ -24,12 +25,37 @@ std::vector ToolRegistry::tools() const { return ret; } + +// helper type for the visitor +template +struct overloads : Ts... { using Ts::operator()...; }; + Content ToolRegistry::invoke(const std::string& name, const nlohmann::json& args) const { auto it = tools_.find(name); if (it == tools_.end()) return "Error: unknown tool: " + name; - return it->second.execute(args); + auto ret = it->second.execute(args); + + ret = std::visit(overloads{ + [](std::string const& x) -> Content { return ensure_utf8(x.c_str()); }, + [](std::vector const& x) -> Content { + std::vector ret = x; + + for (auto& i : ret) + { + if (i.text) + { + std::string valid = ensure_utf8(i.text->c_str()); + i.text = valid; + } + } + + return ret; + }, + }, ret); + + return ret; } std::expected ToolRegistry::create_subregistry(std::vector const& names) const diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 234f4ac..2b1686e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ add_dr_test(test_tool_read_multi_modal) # Add feature validation tests add_dr_test(test_tool_restrictions) add_dr_test(test_n_flag) +add_dr_test(test_prompt_file) add_dr_test(test_load_chat) add_dr_test(process_reasoning) add_dr_test(test_skill_scanner) @@ -156,3 +157,6 @@ add_dr_test(test_multimodal_roundtrip) # Add print_history multimodal tests add_dr_test(test_print_history_multimodal) + +# Add encoding tests +add_dr_test(test_encoding) diff --git a/tests/test_encoding.sh b/tests/test_encoding.sh new file mode 100755 index 0000000..468e822 --- /dev/null +++ b/tests/test_encoding.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# test_encoding.sh - Tests for UTF-8 encoding validation and conversion +# All tests exercise encoding through the read_file tool + +# Test 1: Valid ASCII passes through unchanged +printf 'Hello' > test_ascii.txt +tool_call read_file '{"path":"test_ascii.txt"}' >> mock1.txt +be_done 'done' >> mock1.txt +"${DRHARNESS}" -F chat1.json -n -m "test" -t read_file -d < "mock1.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat1.json 2) +if [ "${RESULT}" != "Hello" ]; then + echo "FAIL: ASCII passthrough - expected 'Hello', got '${RESULT}'" + exit 1 +fi + +# Test 2: Valid UTF-8 2-byte sequence (é = c3a9) passes through unchanged +printf '\xc3\xa9' > test_utf8_2byte.txt +tool_call read_file '{"path":"test_utf8_2byte.txt"}' >> mock2.txt +be_done 'done' >> mock2.txt +"${DRHARNESS}" -F chat2.json -n -m "test" -t read_file -d < "mock2.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat2.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "c3a9" ]; then + echo "FAIL: valid UTF-8 2-byte - expected 'c3a9', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 3: Valid UTF-8 3-byte sequence (中 = e4b8ad) passes through unchanged +printf '\xe4\xb8\xad' > test_utf8_3byte.txt +tool_call read_file '{"path":"test_utf8_3byte.txt"}' >> mock3.txt +be_done 'done' >> mock3.txt +"${DRHARNESS}" -F chat3.json -n -m "test" -t read_file -d < "mock3.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat3.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "e4b8ad" ]; then + echo "FAIL: valid UTF-8 3-byte - expected 'e4b8ad', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 4: Valid UTF-8 4-byte sequence (😀 = f09f9880) passes through unchanged +printf '\xf0\x9f\x98\x80' > test_utf8_4byte.txt +tool_call read_file '{"path":"test_utf8_4byte.txt"}' >> mock4.txt +be_done 'done' >> mock4.txt +"${DRHARNESS}" -F chat4.json -n -m "test" -t read_file -d < "mock4.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat4.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "f09f9880" ]; then + echo "FAIL: valid UTF-8 4-byte - expected 'f09f9880', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 5: Latin-1 byte 0xe9 (é in Latin-1) should be converted to UTF-8 c3a9 +printf '\xe9' > test_latin1_e9.txt +tool_call read_file '{"path":"test_latin1_e9.txt"}' >> mock5.txt +be_done 'done' >> mock5.txt +"${DRHARNESS}" -F chat5.json -n -m "test" -t read_file -d < "mock5.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat5.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "c3a9" ]; then + echo "FAIL: Latin-1 e9 to UTF-8 - expected 'c3a9', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 6: Multiple Latin-1 bytes (café in Latin-1 = 636166e9) +printf '\x63\x61\x66\xe9' > test_latin1_cafe.txt +tool_call read_file '{"path":"test_latin1_cafe.txt"}' >> mock6.txt +be_done 'done' >> mock6.txt +"${DRHARNESS}" -F chat6.json -n -m "test" -t read_file -d < "mock6.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat6.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "636166c3a9" ]; then + echo "FAIL: Latin-1 café to UTF-8 - expected '636166c3a9', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 7: Byte 0x80 (isolated continuation byte) should be converted to c280 +printf '\x80' > test_cont_byte.txt +tool_call read_file '{"path":"test_cont_byte.txt"}' >> mock7.txt +be_done 'done' >> mock7.txt +"${DRHARNESS}" -F chat7.json -n -m "test" -t read_file -d < "mock7.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat7.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "c280" ]; then + echo "FAIL: continuation byte 80 - expected 'c280', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 8: Mixed valid UTF-8 and invalid bytes +# "A" + invalid 0xe9 + "B" = 41e942 -> expected: 41c3a942 +printf '\x41\xe9\x42' > test_mixed.txt +tool_call read_file '{"path":"test_mixed.txt"}' >> mock8.txt +be_done 'done' >> mock8.txt +"${DRHARNESS}" -F chat8.json -n -m "test" -t read_file -d < "mock8.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat8.json 2) +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if [ "${RESULT_HEX}" != "41c3a942" ]; then + echo "FAIL: mixed valid/invalid - expected '41c3a942', got '${RESULT_HEX}'" + exit 1 +fi + +# Test 9: Empty file returns "(empty file)" +printf '' > test_empty.txt +tool_call read_file '{"path":"test_empty.txt"}' >> mock9.txt +be_done 'done' >> mock9.txt +"${DRHARNESS}" -F chat9.json -n -m "test" -t read_file -d < "mock9.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat9.json 2) +if [ "${RESULT}" != "(empty file)" ]; then + echo "FAIL: empty file - expected '(empty file)', got '${RESULT}'" + exit 1 +fi + +# Test 10: File with newline and non-UTF8 content +printf 'line1\n\xe9\nline3\n' > test_multiline.txt +tool_call read_file '{"path":"test_multiline.txt"}' >> mock10.txt +be_done 'done' >> mock10.txt +"${DRHARNESS}" -F chat10.json -n -m "test" -t read_file -d < "mock10.txt" >/dev/null 2>&1 +RESULT=$(dr_result chat10.json 2) +# Check that the file content was properly converted +if ! echo "${RESULT}" | grep -q "line1"; then + echo "FAIL: multiline - missing 'line1' in result" + exit 1 +fi +# Verify the é is properly UTF-8 encoded +RESULT_HEX=$(od -An -tx1 -v <<< "${RESULT}" | tr -d ' \n' | sed 's/0a$//') +if ! echo "${RESULT_HEX}" | grep -q "c3a9"; then + echo "FAIL: multiline - expected UTF-8 é (c3a9) in result, got '${RESULT_HEX}'" + exit 1 +fi + +echo "PASS: encoding tests" diff --git a/tests/test_prompt_file.sh b/tests/test_prompt_file.sh new file mode 100755 index 0000000..aa5c462 --- /dev/null +++ b/tests/test_prompt_file.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# test_prompt_file.sh - Validates the -P flag reads prompt from file + +# Test 1: Basic prompt file reading +echo "This is my prompt from a file" > prompt.txt +be_done 'Response received' | "${DRHARNESS}" -F chat.json -n -P prompt.txt -d + +# Verify the prompt from the file was stored in the chat history +STORED_PROMPT=$(dr_result chat.json 0) +if [ "${STORED_PROMPT}" != "This is my prompt from a file" ]; then + echo "FAIL: prompt_file - prompt not read correctly" + echo "Expected: 'This is my prompt from a file'" + echo "Got: '${STORED_PROMPT}'" + exit 1 +fi + +# Test 2: Multi-line prompt file +cat > multiline_prompt.txt << 'PROMPT' +Line 1 +Line 2 +Line 3 +PROMPT + +be_done 'Multi-line response' | "${DRHARNESS}" -F chat2.json -n -P multiline_prompt.txt -d + +# Verify multi-line prompt was stored correctly +STORED_PROMPT=$(dr_result chat2.json 0) +EXPECTED_MULTILINE="Line 1 +Line 2 +Line 3" + +if [ "${STORED_PROMPT}" != "${EXPECTED_MULTILINE}" ]; then + echo "FAIL: prompt_file multiline - prompt not read correctly" + echo "Expected:" + echo "${EXPECTED_MULTILINE}" + echo "Got:" + echo "${STORED_PROMPT}" + exit 1 +fi + +# Test 3: Non-existent file should fail +if "${DRHARNESS}" -F chat3.json -n -P nonexistent.txt -d < /dev/null 2>stderr_output; then + echo "FAIL: prompt_file nonexistent - should have failed" + cat stderr_output + exit 1 +fi + +echo "PASS: prompt_file" diff --git a/tests/test_tool_shell.sh b/tests/test_tool_shell.sh index 37332e0..a8e847f 100755 --- a/tests/test_tool_shell.sh +++ b/tests/test_tool_shell.sh @@ -40,3 +40,14 @@ be_done 'done' >> mock_responses3.txt timeout 5s "${DRHARNESS}" -F chat3.json -n -m "This is a test" -t run_dangerous_shell -d < "mock_responses3.txt" echo "PASS: tool_shell" + + +seq 1 1044 > long_file +tool_call run_dangerous_shell '{"command":"cat long_file"}' >> mock_responses4.txt +be_done 'done' >> mock_responses4.txt +"${DRHARNESS}" -F chat4.json -n -m "This is a test" -t run_dangerous_shell -d < "mock_responses4.txt" +dr_result chat4.json 2 > long_file_from_harness +echo "" >> long_file + +diff -u long_file long_file_from_harness + diff --git a/tools/shell_tool.cpp b/tools/shell_tool.cpp index 3db7ce4..9d9a8d8 100644 --- a/tools/shell_tool.cpp +++ b/tools/shell_tool.cpp @@ -115,22 +115,29 @@ Tool make_shell_tool(bool safe) { if (pfd.revents & POLLHUP) { // Data might still be available in buffer! if (pfd.revents & POLLIN) { - // Read remaining data - res = read(pfd.fd, buf.data(), buf.size() - 1); - if (res > 0) output += std::string(buf.data(), res); + // Read ALL remaining data in a loop + while (true) { + res = read(pfd.fd, buf.data(), buf.size()); + if (res <= 0) break; // EOF or error + output += std::string(buf.data(), res); + } } break; } - // 3. Read data if available + // 3. Read data if available - keep reading until no more data if (pfd.revents & POLLIN) { - res = read(pfd.fd, buf.data(), buf.size() - 1); - if (res < 0) { // Error - break; - } else if (res == 0) { // EOF - break; + while (true) { + res = read(pfd.fd, buf.data(), buf.size()); + if (res < 0) { + if (errno == EINTR) continue; // Restart if interrupted by signal + break; // Error + } else if (res == 0) { // EOF + break; + } + output += std::string(buf.data(), res); } - output += std::string(buf.data(), res); + break; } }