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:
@@ -4,12 +4,14 @@ mod tools;
|
|||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use tools::{Tool, ToolExecutor};
|
pub use tools::{Tool, ToolExecutor};
|
||||||
|
pub use worker::SentBy;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use conf::Conf;
|
use conf::Conf;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Weak;
|
use std::sync::Weak;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
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,
|
/// 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
|
/// not a date indicating when the API was released. Anthropic adds new features
|
||||||
@@ -58,7 +60,7 @@ pub enum ClaudeError {
|
|||||||
Request(#[from] reqwest::Error),
|
Request(#[from] reqwest::Error),
|
||||||
/// API returned an error response.
|
/// API returned an error response.
|
||||||
#[error("API error: {0}")]
|
#[error("API error: {0}")]
|
||||||
ApiError(String),
|
ApiError(Box<str>),
|
||||||
/// Too many tool use iterations.
|
/// Too many tool use iterations.
|
||||||
#[error("exceeded maximum tool use iterations ({0})")]
|
#[error("exceeded maximum tool use iterations ({0})")]
|
||||||
TooManyIterations(u32),
|
TooManyIterations(u32),
|
||||||
@@ -81,7 +83,7 @@ pub enum ClaudeError {
|
|||||||
/// Requests are processed serially by a background worker to prevent
|
/// Requests are processed serially by a background worker to prevent
|
||||||
/// concurrent API calls.
|
/// concurrent API calls.
|
||||||
pub struct ClaudeApi {
|
pub struct ClaudeApi {
|
||||||
request_tx: mpsc::Sender<ClaudeRequest>,
|
input_tx: mpsc::Sender<Input>,
|
||||||
stop_tx: mpsc::Sender<()>,
|
stop_tx: mpsc::Sender<()>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
worker_handle: tokio::task::JoinHandle<()>,
|
worker_handle: tokio::task::JoinHandle<()>,
|
||||||
@@ -100,10 +102,10 @@ impl ClaudeApi {
|
|||||||
config: ClaudeConfig,
|
config: ClaudeConfig,
|
||||||
tool_executor: Weak<dyn ToolExecutor>,
|
tool_executor: Weak<dyn ToolExecutor>,
|
||||||
) -> Result<Self, ClaudeError> {
|
) -> 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 (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 {
|
let worker_handle = tokio::spawn(async move {
|
||||||
worker.run().await;
|
worker.run().await;
|
||||||
@@ -111,26 +113,29 @@ impl ClaudeApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
request_tx,
|
input_tx,
|
||||||
stop_tx,
|
stop_tx,
|
||||||
worker_handle,
|
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 a future that resolves when the request is complete.
|
||||||
/// Returns `QueueFull` error if the request queue is full.
|
/// 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 (result_tx, result_rx) = oneshot::channel();
|
||||||
|
|
||||||
let request = ClaudeRequest {
|
let timestamp = DateTime::from_timestamp_millis(ts_ms as i64).unwrap_or_else(Utc::now);
|
||||||
prompt: prompt.to_owned(),
|
let msg = ChatMessage {
|
||||||
result_sender: result_tx,
|
sent_by: SentBy::UserToClaude,
|
||||||
|
text: prompt.into(),
|
||||||
|
timestamp,
|
||||||
|
result_sender: Some(result_tx),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.request_tx
|
self.input_tx
|
||||||
.try_send(request)
|
.try_send(Input::Chat(msg))
|
||||||
.map_err(|_| ClaudeError::QueueFull)?;
|
.map_err(|_| ClaudeError::QueueFull)?;
|
||||||
|
|
||||||
// result_rx.await has type Result<Result<String, ClaudeError>, RecvError>
|
// result_rx.await has type Result<Result<String, ClaudeError>, RecvError>
|
||||||
@@ -138,7 +143,26 @@ impl ClaudeApi {
|
|||||||
result_rx.await.map_err(|_| ClaudeError::WorkerGone)?
|
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
|
/// This will cause the current request (if any) to be interrupted at the
|
||||||
/// next opportunity, and all pending requests to receive `StopRequested` errors.
|
/// next opportunity, and all pending requests to receive `StopRequested` errors.
|
||||||
|
|||||||
@@ -1,16 +1,70 @@
|
|||||||
//! Background worker that processes Claude API requests serially.
|
//! Background worker that processes Claude API requests serially.
|
||||||
|
|
||||||
use super::{ANTHROPIC_API_VERSION, ClaudeConfig, ClaudeError, Tool, ToolExecutor};
|
use super::{ANTHROPIC_API_VERSION, ClaudeConfig, ClaudeError, Tool, ToolExecutor};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Weak;
|
use std::sync::Weak;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// A request to be processed by the Claude worker.
|
/// Sent with inputs to claude that claude is expected to respond to. The sender
|
||||||
pub struct ClaudeRequest {
|
/// gives the worker a way to return the results to the caller asynchronously.
|
||||||
pub prompt: String,
|
pub type ResultSender = oneshot::Sender<Result<String, ClaudeError>>;
|
||||||
pub result_sender: 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.
|
/// Background worker that processes Claude API requests serially.
|
||||||
@@ -19,8 +73,12 @@ pub struct ClaudeWorker {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
system_prompt: 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>,
|
tool_executor: Weak<dyn ToolExecutor>,
|
||||||
request_rx: mpsc::Receiver<ClaudeRequest>,
|
input_rx: mpsc::Receiver<Input>,
|
||||||
stop_rx: mpsc::Receiver<()>,
|
stop_rx: mpsc::Receiver<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +89,7 @@ impl ClaudeWorker {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
config: ClaudeConfig,
|
config: ClaudeConfig,
|
||||||
tool_executor: Weak<dyn ToolExecutor>,
|
tool_executor: Weak<dyn ToolExecutor>,
|
||||||
request_rx: mpsc::Receiver<ClaudeRequest>,
|
input_rx: mpsc::Receiver<Input>,
|
||||||
stop_rx: mpsc::Receiver<()>,
|
stop_rx: mpsc::Receiver<()>,
|
||||||
) -> Result<Self, ClaudeError> {
|
) -> Result<Self, ClaudeError> {
|
||||||
let api_key = std::fs::read_to_string(&config.api_key_file)
|
let api_key = std::fs::read_to_string(&config.api_key_file)
|
||||||
@@ -47,8 +105,10 @@ impl ClaudeWorker {
|
|||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
api_key,
|
api_key,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
|
summary: String::new(),
|
||||||
|
messages: Default::default(),
|
||||||
tool_executor,
|
tool_executor,
|
||||||
request_rx,
|
input_rx,
|
||||||
stop_rx,
|
stop_rx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,18 +117,29 @@ impl ClaudeWorker {
|
|||||||
pub async fn run(mut self) {
|
pub async fn run(mut self) {
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
request = self.request_rx.recv() => {
|
input = self.input_rx.recv() => {
|
||||||
let Some(request) = request else {
|
let Some(input) = input else {
|
||||||
// Channel closed, exit
|
// Channel closed, exit
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let result = self.handle_request(&request.prompt).await;
|
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 handle_request was interrupted by stop request, go on to drain the queues
|
||||||
if matches!(result, Err(ClaudeError::StopRequested)) {
|
if matches!(result, Err(ClaudeError::StopRequested)) {
|
||||||
self.handle_stop();
|
self.handle_stop();
|
||||||
}
|
}
|
||||||
// Ignore send errors - the caller may have dropped the receiver
|
// Ignore send errors - the caller may have dropped the receiver
|
||||||
let _ = request.result_sender.send(result);
|
let _ = sender.send(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Input::Compact => {
|
||||||
|
self.handle_compact().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = self.stop_rx.recv() => {
|
_ = self.stop_rx.recv() => {
|
||||||
self.handle_stop();
|
self.handle_stop();
|
||||||
@@ -82,9 +153,18 @@ impl ClaudeWorker {
|
|||||||
// Drain the stop_rx queue
|
// Drain the stop_rx queue
|
||||||
while self.stop_rx.try_recv().is_ok() {}
|
while self.stop_rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
// Drain the request_rx queue and send StopRequested to each
|
// Drain the input_rx queue and send StopRequested to each prompt request
|
||||||
while let Ok(request) = self.request_rx.try_recv() {
|
while let Ok(input) = self.input_rx.try_recv() {
|
||||||
let _ = request.result_sender.send(Err(ClaudeError::StopRequested));
|
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.
|
/// 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;
|
let max_iterations = self.config.claude_max_iterations;
|
||||||
|
|
||||||
// Get tools from the executor if still alive
|
// Get tools from the executor if still alive
|
||||||
let executor = self.tool_executor.upgrade();
|
let executor = self.tool_executor.upgrade();
|
||||||
let tools = executor.as_ref().map(|te| te.tools()).unwrap_or_default();
|
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 {
|
for iteration in 0..max_iterations {
|
||||||
// Check for stop before making API call
|
// Check for stop before making API call
|
||||||
@@ -116,7 +205,7 @@ impl ClaudeWorker {
|
|||||||
model: &self.config.claude_model,
|
model: &self.config.claude_model,
|
||||||
max_tokens: self.config.claude_max_tokens,
|
max_tokens: self.config.claude_max_tokens,
|
||||||
system: &self.system_prompt,
|
system: &self.system_prompt,
|
||||||
messages: messages.clone(),
|
messages: &self.messages,
|
||||||
tools: tools.clone(),
|
tools: tools.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,14 +231,15 @@ impl ClaudeWorker {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if we need to handle tool use
|
// 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
|
// Try to get a strong reference to the executor
|
||||||
let Some(executor) = self.tool_executor.upgrade() else {
|
let Some(executor) = self.tool_executor.upgrade() else {
|
||||||
return Err(ClaudeError::ToolExecutorGone);
|
return Err(ClaudeError::ToolExecutorGone);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add assistant's response to messages
|
// 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
|
// Execute each tool use and collect results
|
||||||
for block in &response.content {
|
for block in &response.content {
|
||||||
@@ -168,7 +258,11 @@ impl ClaudeWorker {
|
|||||||
(err, true)
|
(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
|
.content
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|block| {
|
.filter_map(|block| {
|
||||||
if let ContentBlock::Text { text } = block {
|
if let ContentBlock::Text { text, .. } = block {
|
||||||
Some(text)
|
Some(text)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -205,7 +299,7 @@ struct MessagesRequest<'a> {
|
|||||||
model: &'a str,
|
model: &'a str,
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
system: &'a str,
|
system: &'a str,
|
||||||
messages: Vec<MessageContent>,
|
messages: &'a [MessageContent],
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
}
|
}
|
||||||
@@ -213,33 +307,24 @@ struct MessagesRequest<'a> {
|
|||||||
/// A message in the conversation (can have multiple content blocks).
|
/// A message in the conversation (can have multiple content blocks).
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
struct MessageContent {
|
struct MessageContent {
|
||||||
role: String,
|
role: Box<str>,
|
||||||
content: Vec<ContentBlock>,
|
content: Vec<ContentBlock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageContent {
|
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 {
|
fn assistant(blocks: Vec<ContentBlock>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
role: "assistant".to_owned(),
|
role: "assistant".into(),
|
||||||
content: blocks,
|
content: blocks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
|
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
role: "user".to_owned(),
|
role: "user".into(),
|
||||||
content: vec![ContentBlock::ToolResult {
|
content: vec![ContentBlock::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id: tool_use_id.into(),
|
||||||
content,
|
content: content.into(),
|
||||||
is_error: if is_error { Some(true) } else { None },
|
is_error: if is_error { Some(true) } else { None },
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
@@ -251,16 +336,18 @@ impl MessageContent {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum ContentBlock {
|
enum ContentBlock {
|
||||||
Text {
|
Text {
|
||||||
text: String,
|
text: Box<str>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
timestamp: Option<DateTime<Utc>>,
|
||||||
},
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
id: String,
|
id: Box<str>,
|
||||||
name: String,
|
name: Box<str>,
|
||||||
input: Value,
|
input: Value,
|
||||||
},
|
},
|
||||||
ToolResult {
|
ToolResult {
|
||||||
tool_use_id: String,
|
tool_use_id: Box<str>,
|
||||||
content: String,
|
content: Box<str>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
is_error: Option<bool>,
|
is_error: Option<bool>,
|
||||||
},
|
},
|
||||||
@@ -270,7 +357,7 @@ enum ContentBlock {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct MessagesResponse {
|
struct MessagesResponse {
|
||||||
content: Vec<ContentBlock>,
|
content: Vec<ContentBlock>,
|
||||||
stop_reason: String,
|
stop_reason: Box<str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error response from the Claude API.
|
/// Error response from the Claude API.
|
||||||
@@ -281,5 +368,5 @@ struct ErrorResponse {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ApiErrorDetail {
|
struct ApiErrorDetail {
|
||||||
message: String,
|
message: Box<str>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use crate::signal_jsonrpc::connect_ipc;
|
use crate::signal_jsonrpc::connect_ipc;
|
||||||
use crate::{
|
use crate::{
|
||||||
alertmanager::AlertPost,
|
alertmanager::AlertPost,
|
||||||
claude::{ClaudeApi, ClaudeConfig, Tool, ToolExecutor},
|
claude::{ClaudeApi, ClaudeConfig, SentBy, Tool, ToolExecutor},
|
||||||
log_message::{LogMessage, Origin},
|
log_message::{LogMessage, Origin},
|
||||||
message_handler::{
|
message_handler::{
|
||||||
AdminMessage, AdminMessageResponse, Context, MessageHandler, MessageHandlerResult,
|
AdminMessage, AdminMessageResponse, Context, MessageHandler, MessageHandlerResult,
|
||||||
@@ -147,6 +147,9 @@ enum GatewayCommand {
|
|||||||
/// Stop current Claude request
|
/// Stop current Claude request
|
||||||
#[conf(name = "cs", alias = "CS")]
|
#[conf(name = "cs", alias = "CS")]
|
||||||
ClaudeStop,
|
ClaudeStop,
|
||||||
|
/// Compact Claude's message history
|
||||||
|
#[conf(name = "compact", alias = "COMPACT")]
|
||||||
|
ClaudeCompact,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a gateway command from a string (with or without leading /)
|
/// Parse a gateway command from a string (with or without leading /)
|
||||||
@@ -405,9 +408,13 @@ impl Gateway {
|
|||||||
SignalMessage {
|
SignalMessage {
|
||||||
sender: self.config.signal_account.clone(),
|
sender: self.config.signal_account.clone(),
|
||||||
target,
|
target,
|
||||||
message,
|
message: message.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
}.send(signal_cli).await?;
|
}.send(signal_cli).await?;
|
||||||
|
|
||||||
|
if let Some(claude) = self.claude.get() {
|
||||||
|
claude.record_message(SentBy::System, &message, Utc::now().timestamp_millis() as u64);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("alert_rx is closed, halting service");
|
warn!("alert_rx is closed, halting service");
|
||||||
self.token.cancel();
|
self.token.cancel();
|
||||||
@@ -455,7 +462,7 @@ impl Gateway {
|
|||||||
AdminMessageResponse::new(text)
|
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
|
// Reply to group if message came from a group, otherwise reply to sender
|
||||||
let target = if let Some(group_id) = from_group {
|
let target = if let Some(group_id) = from_group {
|
||||||
@@ -467,9 +474,14 @@ impl Gateway {
|
|||||||
SignalMessage {
|
SignalMessage {
|
||||||
sender: self.config.signal_account.clone(),
|
sender: self.config.signal_account.clone(),
|
||||||
target,
|
target,
|
||||||
message: resp.text,
|
message: resp.text.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
}.send(signal_cli).await?;
|
}.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
|
// Parse the command using conf
|
||||||
let cmd = parse_gateway_command(&data.message).map_err(|err| (400u16, err.into()))?;
|
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 {
|
} 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 {
|
let msg = AdminMessage {
|
||||||
message: data.message.clone(),
|
message: data.message.clone(),
|
||||||
timestamp: data.timestamp,
|
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 {
|
match cmd {
|
||||||
GatewayCommand::Log { filter } => {
|
GatewayCommand::Log { filter } => {
|
||||||
let text = self.log_handler.format_logs(filter.as_deref()).await;
|
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()))?;
|
.ok_or_else(|| (501u16, "claude was not configured".into()))?;
|
||||||
|
|
||||||
let prompt_text = prompt.join(" ");
|
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)),
|
Ok(response) => Ok(AdminMessageResponse::new(response)),
|
||||||
Err(err) => Err((500, err.to_string().into())),
|
Err(err) => Err((500, err.to_string().into())),
|
||||||
}
|
}
|
||||||
@@ -727,6 +766,15 @@ impl Gateway {
|
|||||||
claude.request_stop();
|
claude.request_stop();
|
||||||
Ok(AdminMessageResponse::new("stop requested"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub struct AdminMessageResponse {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
/// Optional file attachments to include with the response.
|
/// Optional file attachments to include with the response.
|
||||||
pub attachments: Vec<PathBuf>,
|
pub attachments: Vec<PathBuf>,
|
||||||
|
/// Whether this response came from Claude (true) or the system (false).
|
||||||
|
pub(crate) is_claude: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminMessageResponse {
|
impl AdminMessageResponse {
|
||||||
@@ -43,6 +45,7 @@ impl AdminMessageResponse {
|
|||||||
Self {
|
Self {
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
is_claude: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +65,12 @@ impl AdminMessageResponse {
|
|||||||
self.attachments.extend(paths.into_iter().map(Into::into));
|
self.attachments.extend(paths.into_iter().map(Into::into));
|
||||||
self
|
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`].
|
/// Builder for constructing an [`AdminMessageResponse`].
|
||||||
@@ -95,6 +104,7 @@ impl AdminMessageResponseBuilder {
|
|||||||
AdminMessageResponse {
|
AdminMessageResponse {
|
||||||
text: self.text,
|
text: self.text,
|
||||||
attachments: self.attachments,
|
attachments: self.attachments,
|
||||||
|
is_claude: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user