expose signal chat history to claude as message history

with role attributions, and a new compaction command which just
discards the message buffer.
This commit is contained in:
Chris Beck
2025-12-08 00:43:50 -07:00
parent 9826bbf8c2
commit 84e2d57688
4 changed files with 238 additions and 69 deletions
+38 -14
View File
@@ -4,12 +4,14 @@ mod tools;
mod worker;
pub use tools::{Tool, ToolExecutor};
pub use worker::SentBy;
use chrono::{DateTime, Utc};
use conf::Conf;
use std::path::PathBuf;
use std::sync::Weak;
use tokio::sync::{mpsc, oneshot};
use worker::{ClaudeRequest, ClaudeWorker};
use worker::{ChatMessage, ClaudeWorker, Input};
/// The Anthropic API version header value. This is a stable version identifier,
/// not a date indicating when the API was released. Anthropic adds new features
@@ -58,7 +60,7 @@ pub enum ClaudeError {
Request(#[from] reqwest::Error),
/// API returned an error response.
#[error("API error: {0}")]
ApiError(String),
ApiError(Box<str>),
/// Too many tool use iterations.
#[error("exceeded maximum tool use iterations ({0})")]
TooManyIterations(u32),
@@ -81,7 +83,7 @@ pub enum ClaudeError {
/// Requests are processed serially by a background worker to prevent
/// concurrent API calls.
pub struct ClaudeApi {
request_tx: mpsc::Sender<ClaudeRequest>,
input_tx: mpsc::Sender<Input>,
stop_tx: mpsc::Sender<()>,
#[allow(dead_code)]
worker_handle: tokio::task::JoinHandle<()>,
@@ -100,10 +102,10 @@ impl ClaudeApi {
config: ClaudeConfig,
tool_executor: Weak<dyn ToolExecutor>,
) -> Result<Self, ClaudeError> {
let (request_tx, request_rx) = mpsc::channel(REQUEST_QUEUE_SIZE);
let (input_tx, input_rx) = mpsc::channel(REQUEST_QUEUE_SIZE);
let (stop_tx, stop_rx) = mpsc::channel(REQUEST_QUEUE_SIZE);
let worker = ClaudeWorker::new(config, tool_executor, request_rx, stop_rx)?;
let worker = ClaudeWorker::new(config, tool_executor, input_rx, stop_rx)?;
let worker_handle = tokio::spawn(async move {
worker.run().await;
@@ -111,26 +113,29 @@ impl ClaudeApi {
});
Ok(Self {
request_tx,
input_tx,
stop_tx,
worker_handle,
})
}
/// Send a request to the Claude API and return the response text.
/// Send a prompt to the Claude API, execute tools etc., and return the response text.
///
/// Returns a future that resolves when the request is complete.
/// Returns `QueueFull` error if the request queue is full.
pub async fn request(&self, prompt: &str) -> Result<String, ClaudeError> {
pub async fn request(&self, prompt: &str, ts_ms: u64) -> Result<String, ClaudeError> {
let (result_tx, result_rx) = oneshot::channel();
let request = ClaudeRequest {
prompt: prompt.to_owned(),
result_sender: result_tx,
let timestamp = DateTime::from_timestamp_millis(ts_ms as i64).unwrap_or_else(Utc::now);
let msg = ChatMessage {
sent_by: SentBy::UserToClaude,
text: prompt.into(),
timestamp,
result_sender: Some(result_tx),
};
self.request_tx
.try_send(request)
self.input_tx
.try_send(Input::Chat(msg))
.map_err(|_| ClaudeError::QueueFull)?;
// result_rx.await has type Result<Result<String, ClaudeError>, RecvError>
@@ -138,7 +143,26 @@ impl ClaudeApi {
result_rx.await.map_err(|_| ClaudeError::WorkerGone)?
}
/// Request the worker to stop processing.
/// Record a message into claude's chat log that claude is not expected to respond to
pub fn record_message(&self, sent_by: SentBy, text: &str, ts_ms: u64) {
let timestamp = DateTime::from_timestamp_millis(ts_ms as i64).unwrap_or_else(Utc::now);
let msg = ChatMessage {
sent_by,
text: text.into(),
timestamp,
result_sender: None,
};
let _ = self.input_tx.try_send(Input::Chat(msg));
}
/// Request the worker to perform compaction (summarizing their recent message history and discarding it)
pub fn request_compaction(&self) {
let _ = self.input_tx.try_send(Input::Compact);
}
/// Request the worker to interrupt any prompts it is responding to, discard any queued messages,
/// and get ready to accept different prompts.
///
/// This will cause the current request (if any) to be interrupted at the
/// next opportunity, and all pending requests to receive `StopRequested` errors.
+135 -48
View File
@@ -1,16 +1,70 @@
//! Background worker that processes Claude API requests serially.
use super::{ANTHROPIC_API_VERSION, ClaudeConfig, ClaudeError, Tool, ToolExecutor};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Weak;
use tokio::sync::{mpsc, oneshot};
use tracing::info;
/// A request to be processed by the Claude worker.
pub struct ClaudeRequest {
pub prompt: String,
pub result_sender: oneshot::Sender<Result<String, ClaudeError>>,
/// Sent with inputs to claude that claude is expected to respond to. The sender
/// gives the worker a way to return the results to the caller asynchronously.
pub type ResultSender = oneshot::Sender<Result<String, ClaudeError>>;
/// Indicates the "role" i.e. the manner in which a particular message was sent
pub enum SentBy {
/// User message directed at the system (commands, etc.)
UserToSystem,
/// User message directed at Claude (prompts)
UserToClaude,
/// Response from Claude
Claude,
/// System-generated message
System,
/// Alert from alertmanager
AlertManager,
}
impl SentBy {
fn role(&self) -> &str {
match self {
Self::UserToSystem => "user (speaking to system)",
Self::UserToClaude => "user (speaking to assistant)",
Self::Claude => "assistant",
Self::System => "system",
Self::AlertManager => "alertmanager",
}
}
}
/// An input to the worker sent through the channel
pub enum Input {
Chat(ChatMessage),
Compact,
}
/// A chat message
pub struct ChatMessage {
pub sent_by: SentBy,
pub timestamp: DateTime<Utc>,
pub text: Box<str>,
/// Present when claude is expected to respond to the message
pub result_sender: Option<ResultSender>,
}
impl ChatMessage {
fn into_content_and_sender(self) -> (MessageContent, Option<ResultSender>) {
let mc = MessageContent {
role: self.sent_by.role().into(),
content: vec![ContentBlock::Text {
text: self.text,
timestamp: Some(self.timestamp),
}],
};
(mc, self.result_sender)
}
}
/// Background worker that processes Claude API requests serially.
@@ -19,8 +73,12 @@ pub struct ClaudeWorker {
client: reqwest::Client,
api_key: String,
system_prompt: String,
// FIXME: use this and append to system prompt within <summary> </summary> tags
#[allow(dead_code)]
summary: String,
messages: Vec<MessageContent>,
tool_executor: Weak<dyn ToolExecutor>,
request_rx: mpsc::Receiver<ClaudeRequest>,
input_rx: mpsc::Receiver<Input>,
stop_rx: mpsc::Receiver<()>,
}
@@ -31,7 +89,7 @@ impl ClaudeWorker {
pub fn new(
config: ClaudeConfig,
tool_executor: Weak<dyn ToolExecutor>,
request_rx: mpsc::Receiver<ClaudeRequest>,
input_rx: mpsc::Receiver<Input>,
stop_rx: mpsc::Receiver<()>,
) -> Result<Self, ClaudeError> {
let api_key = std::fs::read_to_string(&config.api_key_file)
@@ -47,8 +105,10 @@ impl ClaudeWorker {
client: reqwest::Client::new(),
api_key,
system_prompt,
summary: String::new(),
messages: Default::default(),
tool_executor,
request_rx,
input_rx,
stop_rx,
})
}
@@ -57,18 +117,29 @@ impl ClaudeWorker {
pub async fn run(mut self) {
loop {
tokio::select! {
request = self.request_rx.recv() => {
let Some(request) = request else {
input = self.input_rx.recv() => {
let Some(input) = input else {
// Channel closed, exit
break;
};
let result = self.handle_request(&request.prompt).await;
// If handle_request was interrupted by stop request, go on to drain the queues
if matches!(result, Err(ClaudeError::StopRequested)) {
self.handle_stop();
match input {
Input::Chat(msg) => {
let (mc, maybe_sender) = msg.into_content_and_sender();
self.messages.push(mc);
if let Some(sender) = maybe_sender {
let result = self.handle_request().await;
// If handle_request was interrupted by stop request, go on to drain the queues
if matches!(result, Err(ClaudeError::StopRequested)) {
self.handle_stop();
}
// Ignore send errors - the caller may have dropped the receiver
let _ = sender.send(result);
}
},
Input::Compact => {
self.handle_compact().await;
}
}
// Ignore send errors - the caller may have dropped the receiver
let _ = request.result_sender.send(result);
}
_ = self.stop_rx.recv() => {
self.handle_stop();
@@ -82,9 +153,18 @@ impl ClaudeWorker {
// Drain the stop_rx queue
while self.stop_rx.try_recv().is_ok() {}
// Drain the request_rx queue and send StopRequested to each
while let Ok(request) = self.request_rx.try_recv() {
let _ = request.result_sender.send(Err(ClaudeError::StopRequested));
// Drain the input_rx queue and send StopRequested to each prompt request
while let Ok(input) = self.input_rx.try_recv() {
match input {
Input::Chat(msg) => {
let (mc, maybe_sender) = msg.into_content_and_sender();
self.messages.push(mc);
if let Some(sender) = maybe_sender {
let _ = sender.send(Err(ClaudeError::StopRequested));
}
}
Input::Compact => {}
}
}
}
@@ -97,16 +177,25 @@ impl ClaudeWorker {
}
}
/// Perform compaction
async fn handle_compact(&mut self) {
// FIXME: we should actually try to summarize messages using an api request, and then store it, before tossing messages
self.messages.clear();
}
/// Handle a single request to the Claude API.
async fn handle_request(&mut self, prompt: &str) -> Result<String, ClaudeError> {
async fn handle_request(&mut self) -> Result<String, ClaudeError> {
let max_iterations = self.config.claude_max_iterations;
// Get tools from the executor if still alive
let executor = self.tool_executor.upgrade();
let tools = executor.as_ref().map(|te| te.tools()).unwrap_or_default();
let mut messages = vec![MessageContent::user(prompt)];
info!("Claude request: {}", prompt);
if let Some(last) = self.messages.last() {
if let Some(ContentBlock::Text { text, .. }) = last.content.first() {
info!("Claude request: {}", text);
}
}
for iteration in 0..max_iterations {
// Check for stop before making API call
@@ -116,7 +205,7 @@ impl ClaudeWorker {
model: &self.config.claude_model,
max_tokens: self.config.claude_max_tokens,
system: &self.system_prompt,
messages: messages.clone(),
messages: &self.messages,
tools: tools.clone(),
};
@@ -142,14 +231,15 @@ impl ClaudeWorker {
);
// Check if we need to handle tool use
if response.stop_reason == "tool_use" {
if response.stop_reason.as_ref() == "tool_use" {
// Try to get a strong reference to the executor
let Some(executor) = self.tool_executor.upgrade() else {
return Err(ClaudeError::ToolExecutorGone);
};
// Add assistant's response to messages
messages.push(MessageContent::assistant(response.content.clone()));
self.messages
.push(MessageContent::assistant(response.content.clone()));
// Execute each tool use and collect results
for block in &response.content {
@@ -168,7 +258,11 @@ impl ClaudeWorker {
(err, true)
}
};
messages.push(MessageContent::tool_result(id.clone(), result, is_error));
self.messages.push(MessageContent::tool_result(
id.to_string(),
result,
is_error,
));
}
}
@@ -182,7 +276,7 @@ impl ClaudeWorker {
.content
.into_iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
if let ContentBlock::Text { text, .. } = block {
Some(text)
} else {
None
@@ -205,7 +299,7 @@ struct MessagesRequest<'a> {
model: &'a str,
max_tokens: u32,
system: &'a str,
messages: Vec<MessageContent>,
messages: &'a [MessageContent],
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<Tool>,
}
@@ -213,33 +307,24 @@ struct MessagesRequest<'a> {
/// A message in the conversation (can have multiple content blocks).
#[derive(Clone, Debug, Serialize, Deserialize)]
struct MessageContent {
role: String,
role: Box<str>,
content: Vec<ContentBlock>,
}
impl MessageContent {
fn user(text: &str) -> Self {
Self {
role: "user".to_owned(),
content: vec![ContentBlock::Text {
text: text.to_owned(),
}],
}
}
fn assistant(blocks: Vec<ContentBlock>) -> Self {
Self {
role: "assistant".to_owned(),
role: "assistant".into(),
content: blocks,
}
}
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
Self {
role: "user".to_owned(),
role: "user".into(),
content: vec![ContentBlock::ToolResult {
tool_use_id,
content,
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: if is_error { Some(true) } else { None },
}],
}
@@ -251,16 +336,18 @@ impl MessageContent {
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentBlock {
Text {
text: String,
text: Box<str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<DateTime<Utc>>,
},
ToolUse {
id: String,
name: String,
id: Box<str>,
name: Box<str>,
input: Value,
},
ToolResult {
tool_use_id: String,
content: String,
tool_use_id: Box<str>,
content: Box<str>,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
@@ -270,7 +357,7 @@ enum ContentBlock {
#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
stop_reason: String,
stop_reason: Box<str>,
}
/// Error response from the Claude API.
@@ -281,5 +368,5 @@ struct ErrorResponse {
#[derive(Deserialize)]
struct ApiErrorDetail {
message: String,
message: Box<str>,
}
+55 -7
View File
@@ -4,7 +4,7 @@
use crate::signal_jsonrpc::connect_ipc;
use crate::{
alertmanager::AlertPost,
claude::{ClaudeApi, ClaudeConfig, Tool, ToolExecutor},
claude::{ClaudeApi, ClaudeConfig, SentBy, Tool, ToolExecutor},
log_message::{LogMessage, Origin},
message_handler::{
AdminMessage, AdminMessageResponse, Context, MessageHandler, MessageHandlerResult,
@@ -147,6 +147,9 @@ enum GatewayCommand {
/// Stop current Claude request
#[conf(name = "cs", alias = "CS")]
ClaudeStop,
/// Compact Claude's message history
#[conf(name = "compact", alias = "COMPACT")]
ClaudeCompact,
}
/// Parse a gateway command from a string (with or without leading /)
@@ -405,9 +408,13 @@ impl Gateway {
SignalMessage {
sender: self.config.signal_account.clone(),
target,
message,
message: message.clone(),
attachments,
}.send(signal_cli).await?;
if let Some(claude) = self.claude.get() {
claude.record_message(SentBy::System, &message, Utc::now().timestamp_millis() as u64);
}
} else {
warn!("alert_rx is closed, halting service");
self.token.cancel();
@@ -455,7 +462,7 @@ impl Gateway {
AdminMessageResponse::new(text)
});
let attachments = resp.attachments.into_iter().map(|p| p.to_str().expect("attachments must have utf8 paths").to_owned()).collect();
let attachments = resp.attachments.iter().map(|p| p.to_str().expect("attachments must have utf8 paths").to_owned()).collect();
// Reply to group if message came from a group, otherwise reply to sender
let target = if let Some(group_id) = from_group {
@@ -467,9 +474,14 @@ impl Gateway {
SignalMessage {
sender: self.config.signal_account.clone(),
target,
message: resp.text,
message: resp.text.clone(),
attachments,
}.send(signal_cli).await?;
if let Some(claude) = self.claude.get() {
let sent_by = if resp.is_claude { SentBy::Claude } else { SentBy::System };
claude.record_message(sent_by, &resp.text, Utc::now().timestamp_millis() as u64);
}
}
}
}
@@ -488,8 +500,31 @@ impl Gateway {
// Parse the command using conf
let cmd = parse_gateway_command(&data.message).map_err(|err| (400u16, err.into()))?;
self.handle_gateway_command(cmd).await
// Record this as a system command in Claude's history, unless it's a Claude
// prompt command (which will be recorded when we call request())
let is_claude = matches!(cmd, GatewayCommand::Claude { .. });
if !is_claude {
if let Some(claude) = self.claude.get() {
claude.record_message(
SentBy::UserToSystem,
&data.message,
data.timestamp,
);
}
}
let resp = self.handle_gateway_command(cmd, data.timestamp).await?;
Ok(if is_claude { resp.from_claude() } else { resp })
} else if let Some(handler) = &self.message_handler {
// Record this as a system message in Claude's history (not directed at Claude)
if let Some(claude) = self.claude.get() {
claude.record_message(
SentBy::UserToSystem,
&data.message,
data.timestamp,
);
}
let msg = AdminMessage {
message: data.message.clone(),
timestamp: data.timestamp,
@@ -566,7 +601,11 @@ impl Gateway {
}
}
async fn handle_gateway_command(&self, cmd: GatewayCommand) -> MessageHandlerResult {
async fn handle_gateway_command(
&self,
cmd: GatewayCommand,
ts_ms: u64,
) -> MessageHandlerResult {
match cmd {
GatewayCommand::Log { filter } => {
let text = self.log_handler.format_logs(filter.as_deref()).await;
@@ -713,7 +752,7 @@ impl Gateway {
.ok_or_else(|| (501u16, "claude was not configured".into()))?;
let prompt_text = prompt.join(" ");
match claude.request(&prompt_text).await {
match claude.request(&prompt_text, ts_ms).await {
Ok(response) => Ok(AdminMessageResponse::new(response)),
Err(err) => Err((500, err.to_string().into())),
}
@@ -727,6 +766,15 @@ impl Gateway {
claude.request_stop();
Ok(AdminMessageResponse::new("stop requested"))
}
GatewayCommand::ClaudeCompact => {
let claude = self
.claude
.get()
.ok_or_else(|| (501u16, "claude was not configured".into()))?;
claude.request_compaction();
Ok(AdminMessageResponse::new("compaction requested"))
}
}
}
+10
View File
@@ -35,6 +35,8 @@ pub struct AdminMessageResponse {
pub text: String,
/// Optional file attachments to include with the response.
pub attachments: Vec<PathBuf>,
/// Whether this response came from Claude (true) or the system (false).
pub(crate) is_claude: bool,
}
impl AdminMessageResponse {
@@ -43,6 +45,7 @@ impl AdminMessageResponse {
Self {
text: text.into(),
attachments: Vec::new(),
is_claude: false,
}
}
@@ -62,6 +65,12 @@ impl AdminMessageResponse {
self.attachments.extend(paths.into_iter().map(Into::into));
self
}
/// Mark this response as coming from Claude.
pub(crate) fn from_claude(mut self) -> Self {
self.is_claude = true;
self
}
}
/// Builder for constructing an [`AdminMessageResponse`].
@@ -95,6 +104,7 @@ impl AdminMessageResponseBuilder {
AdminMessageResponse {
text: self.text,
attachments: self.attachments,
is_claude: false,
}
}
}