diff --git a/crates/genie-core/src/server.rs b/crates/genie-core/src/server.rs index d9918a85..bf42fe88 100644 --- a/crates/genie-core/src/server.rs +++ b/crates/genie-core/src/server.rs @@ -1070,7 +1070,11 @@ async fn handle_chat_stream( } summary } else { - let sanitized = crate::security::sandbox::sanitize_output(&llm_response); + let sanitized = if crate::tools::is_unparsed_tool_call(&llm_response) { + crate::tools::UNPARSED_TOOL_CALL_FALLBACK.to_string() + } else { + crate::security::sandbox::sanitize_output(&llm_response) + }; if !state.pending.is_empty() && state.mode == StreamMode::Undecided { write_stream_event( writer, @@ -1196,7 +1200,11 @@ pub async fn process_chat_turn( ) .await } else { - let sanitized = crate::security::sandbox::sanitize_output(&llm_response); + let sanitized = if crate::tools::is_unparsed_tool_call(&llm_response) { + crate::tools::UNPARSED_TOOL_CALL_FALLBACK.to_string() + } else { + crate::security::sandbox::sanitize_output(&llm_response) + }; conversations.append_or_log(conv_id, "assistant", &sanitized, None); sanitized }; @@ -2240,6 +2248,8 @@ async fn handle_openai_chat( } else { tool_result.output } + } else if crate::tools::is_unparsed_tool_call(&llm_response) { + crate::tools::UNPARSED_TOOL_CALL_FALLBACK.to_string() } else { llm_response }; diff --git a/crates/genie-core/src/tools/mod.rs b/crates/genie-core/src/tools/mod.rs index de0cc149..fb6a4b86 100644 --- a/crates/genie-core/src/tools/mod.rs +++ b/crates/genie-core/src/tools/mod.rs @@ -11,4 +11,7 @@ pub(crate) mod web_search; pub use actuation::{PendingConfirmation, RequestOrigin}; pub use dispatch::{ToolActionClass, ToolCall, ToolDispatcher, ToolExecutionContext, ToolResult}; -pub use parser::{parse_tool_calls_for_eval, try_tool_call, try_tool_call_with_context}; +pub use parser::{ + UNPARSED_TOOL_CALL_FALLBACK, is_unparsed_tool_call, parse_tool_calls_for_eval, try_tool_call, + try_tool_call_with_context, +}; diff --git a/crates/genie-core/src/tools/parser.rs b/crates/genie-core/src/tools/parser.rs index 667898d9..18e8ef7d 100644 --- a/crates/genie-core/src/tools/parser.rs +++ b/crates/genie-core/src/tools/parser.rs @@ -27,6 +27,27 @@ pub async fn try_tool_call_with_context( Some(tools.execute_with_context(&call, exec_ctx).await) } +/// Shown to the user instead of leaking raw tool-call JSON when the model +/// produced something that looks like a tool call but could not be parsed +/// (issue #378). +pub const UNPARSED_TOOL_CALL_FALLBACK: &str = + "Sorry, I didn't quite catch that — could you say it another way?"; + +/// True when the model output structurally resembles a tool call but no valid +/// tool-call JSON can be extracted from it — e.g. the model emitted invalid +/// JSON like `{"seconds": 60*60*12}` (issue #378). Such output must not be +/// rendered to the user as a normal reply, which would leak raw tool-call JSON. +/// Normal prose, or a response whose tool-call JSON parses cleanly (and is +/// therefore handled by `try_tool_call_with_context`), returns false. +pub fn is_unparsed_tool_call(response: &str) -> bool { + let trimmed = response.trim(); + let looks_toolish = (trimmed.starts_with('{') || trimmed.starts_with("```")) + && (trimmed.contains("\"tool\"") + || trimmed.contains("\"arguments\"") + || trimmed.contains("\"name\"")); + looks_toolish && extract_json(response).is_none() +} + /// Parse tool calls from model output without executing them. /// /// This is intentionally separate from `try_tool_call_with_context`: evaluation @@ -341,6 +362,32 @@ mod tests { assert!(extract_json(input).is_none()); } + #[test] + fn unparsed_tool_call_detected_for_invalid_json() { + // The exact Jetson leak (issue #378): `60*60*12` is a JS expression, + // not valid JSON, so the tool call never parses and would otherwise be + // shown to the user verbatim. + let leak = r#"{"tool":"set_timer","arguments":{"seconds":60*60*12,"label":"meeting"}}"#; + assert!(is_unparsed_tool_call(leak)); + // Also when fenced. + assert!(is_unparsed_tool_call( + "```json\n{\"tool\":\"set_timer\",\"arguments\":{\"seconds\":60*60}}\n```" + )); + } + + #[test] + fn valid_tool_call_is_not_flagged_as_unparsed() { + let ok = r#"{"tool":"set_timer","arguments":{"seconds":300}}"#; + assert!(!is_unparsed_tool_call(ok)); + } + + #[test] + fn normal_prose_is_not_flagged_as_unparsed() { + assert!(!is_unparsed_tool_call("The timer is set for 5 minutes.")); + assert!(!is_unparsed_tool_call("Hi! How can I help you today?")); + assert!(!is_unparsed_tool_call("")); + } + #[test] fn nested_json_in_arguments() { let input = r#"{"tool": "home_control", "arguments": {"entity": "thermostat", "action": "set_temperature", "value": 72}}"#; diff --git a/deploy/scripts/genie-disable-gui.sh b/deploy/scripts/genie-disable-gui.sh new file mode 100755 index 00000000..bc2f13db --- /dev/null +++ b/deploy/scripts/genie-disable-gui.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# GeniePod — disable the desktop GUI on a Jetson (L4T / Ubuntu) +# +# The desktop session (display manager + X/Wayland + GNOME shell) holds a few +# hundred MB of the Orin Nano's 8 GB *unified* memory — the same pool the local +# LLM/KV-cache competes for. On a headless appliance the GUI is dead weight, so +# this drops to a console-only boot and frees that memory for the agent. +# +# Usage (run on the Jetson, as root): +# sudo bash /opt/geniepod/scripts/genie-disable-gui.sh # disable GUI +# sudo bash /opt/geniepod/scripts/genie-disable-gui.sh --enable # restore GUI +# +# It is idempotent and reversible. Best run over SSH — it tears down the local +# desktop session immediately, so a monitor/keyboard session would be dropped +# to a text console (SSH connections survive). + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script must run as root. Re-run with: sudo $0 $*" >&2 + exit 1 +fi + +# Name of the active display manager unit (e.g. gdm3.service / lightdm.service), +# resolved via the standard display-manager.service alias symlink. Empty on a +# truly headless image with no DM installed. +dm_unit="" +if [[ -e /etc/systemd/system/display-manager.service ]]; then + dm_unit="$(basename "$(readlink -f /etc/systemd/system/display-manager.service)")" +fi + +avail_mb() { free -m | awk '/^Mem:/ {print $7}'; } + +# ---- re-enable path ------------------------------------------------------- +if [[ "${1:-}" == "--enable" || "${1:-}" == "--on" ]]; then + systemctl set-default graphical.target + if [[ -n "$dm_unit" ]]; then + systemctl enable "$dm_unit" >/dev/null 2>&1 || true + fi + echo "GUI re-enabled (default target: $(systemctl get-default))." + echo "Start it now without rebooting: sudo systemctl isolate graphical.target" + exit 0 +fi + +# ---- disable path --------------------------------------------------------- +before="$(avail_mb)" + +# 1. Persist across reboots: boot into the console (multi-user) target, which +# never pulls in graphical.target / the display manager. +systemctl set-default multi-user.target + +# 2. Stop the GUI now. `display-manager.service` is the distro-agnostic alias, +# so this works whether the image ships gdm3, lightdm, or sddm. +if [[ -n "$dm_unit" ]]; then + echo "Display manager: ${dm_unit} — disabling and stopping" + systemctl disable "$dm_unit" >/dev/null 2>&1 || true + systemctl stop display-manager.service 2>/dev/null || true +else + echo "No display manager detected (already headless?) — only setting the default target." +fi + +# 3. Drop the current graphical session immediately so the memory is freed +# without waiting for a reboot. SSH sessions are unaffected. +systemctl isolate multi-user.target 2>/dev/null || true + +sleep 2 +after="$(avail_mb)" + +echo +echo "GUI disabled." +echo " default target : $(systemctl get-default)" +echo " available RAM : ${before} MB -> ${after} MB" +echo +echo "Re-enable with: sudo $0 --enable (then reboot, or: sudo systemctl isolate graphical.target)"