Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/genie-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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
};
Expand Down
5 changes: 4 additions & 1 deletion crates/genie-core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
47 changes: 47 additions & 0 deletions crates/genie-core/src/tools/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}"#;
Expand Down
74 changes: 74 additions & 0 deletions deploy/scripts/genie-disable-gui.sh
Original file line number Diff line number Diff line change
@@ -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)"
Loading