Skip to content

Commit 01c0e6a

Browse files
authored
feat(PN-8): implement down command and smart context management (#50)
Add PID file-based process management (write on up, read/signal on down, check on status) with graceful SIGTERM/SIGINT shutdown. Add conversation compaction via LLM summarization when token count approaches the configurable context_budget. Replaces the crude 100-message rolling window with intelligent compaction that preserves key context while keeping conversations unbounded. Closes #8
1 parent 62f0cfd commit 01c0e6a

15 files changed

Lines changed: 329 additions & 27 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ chrono-tz = "0.10"
5151
# Misc
5252
uuid = { version = "1", features = ["v4"] }
5353
chrono = { version = "0.4", features = ["serde"] }
54+
libc = "0.2"
5455

5556
# Shared types
5657
echo-system-types = { git = "https://github.com/dnacenta/echo-system-types", tag = "v0.2.0" }

src/chat/repl.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ pub async fn run(
6060
content: MessageContent::Text(input.to_string()),
6161
});
6262

63+
// Compact conversation if approaching context budget
64+
crate::context::compact_if_needed(
65+
&mut conversation,
66+
provider,
67+
config.llm.context_budget,
68+
config.llm.max_tokens,
69+
)
70+
.await;
71+
6372
// Tool definitions
6473
let tool_defs = if provider.supports_tools() && !tools.is_empty() {
6574
Some(tools.definitions())
@@ -169,12 +178,6 @@ pub async fn run(
169178
}
170179
}
171180
}
172-
173-
// Keep conversation bounded
174-
if conversation.len() > 100 {
175-
let drain_count = conversation.len() - 100;
176-
conversation.drain(..drain_count);
177-
}
178181
}
179182

180183
// Save session to EPHEMERAL.md

src/cli/down.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
1+
use crate::config::Config;
2+
use crate::pidfile;
3+
14
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
2-
// Phase 1: simple — just tell the user to Ctrl+C
3-
// Future: PID file, graceful shutdown signal
4-
eprintln!("Use Ctrl+C to stop a running entity, or kill the process.");
5+
let config = Config::load()?;
6+
let root_dir = config.root_dir()?;
7+
8+
let pid = match pidfile::read(&root_dir) {
9+
Some(pid) => pid,
10+
None => {
11+
eprintln!("No running entity found (no PID file).");
12+
return Ok(());
13+
}
14+
};
15+
16+
if !pidfile::is_alive(pid) {
17+
eprintln!("Entity is not running (stale PID file, pid {}).", pid);
18+
pidfile::remove(&root_dir);
19+
return Ok(());
20+
}
21+
22+
println!("Stopping entity (pid {})...", pid);
23+
24+
if !pidfile::kill(pid) {
25+
return Err(format!("Failed to send SIGTERM to pid {}", pid).into());
26+
}
27+
28+
// Wait up to 10 seconds for the process to exit
29+
for _ in 0..100 {
30+
if !pidfile::is_alive(pid) {
31+
pidfile::remove(&root_dir);
32+
println!("Entity stopped.");
33+
return Ok(());
34+
}
35+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
36+
}
37+
38+
eprintln!("Entity did not stop within 10 seconds (pid {}).", pid);
539
Ok(())
640
}

src/cli/status.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::config::Config;
2+
use crate::pidfile;
23

34
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
45
let config = Config::load()?;
6+
let root_dir = config.root_dir()?;
57

68
println!("Entity: {}", config.entity.name);
79
println!(
@@ -26,19 +28,26 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
2628
);
2729
}
2830

29-
// Check if server is running
30-
let url = format!(
31-
"http://{}:{}/health",
32-
config.server.host, config.server.port
33-
);
34-
match reqwest::get(&url).await {
35-
Ok(resp) if resp.status().is_success() => {
36-
println!("Status: RUNNING");
31+
// Check PID file first, then fall back to health endpoint
32+
let status = match pidfile::read(&root_dir) {
33+
Some(pid) if pidfile::is_alive(pid) => format!("RUNNING (pid {})", pid),
34+
Some(pid) => {
35+
pidfile::remove(&root_dir);
36+
format!("STOPPED (stale pid {})", pid)
3737
}
38-
_ => {
39-
println!("Status: STOPPED");
38+
None => {
39+
// No PID file — try health endpoint as fallback
40+
let url = format!(
41+
"http://{}:{}/health",
42+
config.server.host, config.server.port
43+
);
44+
match reqwest::get(&url).await {
45+
Ok(resp) if resp.status().is_success() => "RUNNING".to_string(),
46+
_ => "STOPPED".to_string(),
47+
}
4048
}
41-
}
49+
};
4250

51+
println!("Status: {}", status);
4352
Ok(())
4453
}

src/config/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub struct LlmConfig {
5555
pub model: String,
5656
#[serde(default = "default_max_tokens")]
5757
pub max_tokens: u32,
58+
/// Maximum estimated tokens in conversation before compaction triggers (0 = default 150k).
59+
#[serde(default)]
60+
pub context_budget: usize,
5861
}
5962

6063
#[derive(Debug, Clone, Serialize, Deserialize)]

src/context.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
use echo_system_types::llm::{ContentBlock, LmProvider, Message, MessageContent, Role};
2+
3+
/// Default context budget in estimated tokens (leaves room for system prompt + response).
4+
const DEFAULT_CONTEXT_BUDGET: usize = 150_000;
5+
6+
/// How many of the most recent messages to always keep uncompacted.
7+
const KEEP_RECENT: usize = 20;
8+
9+
/// Minimum messages before compaction is even considered.
10+
const MIN_MESSAGES_FOR_COMPACTION: usize = 30;
11+
12+
/// Rough chars-per-token estimate for English text.
13+
const CHARS_PER_TOKEN: usize = 4;
14+
15+
/// Estimate the token count of a single message.
16+
pub fn estimate_message_tokens(msg: &Message) -> usize {
17+
let chars = match &msg.content {
18+
MessageContent::Text(s) => s.len(),
19+
MessageContent::Blocks(blocks) => blocks
20+
.iter()
21+
.map(|block| match block {
22+
ContentBlock::Text { text } => text.len(),
23+
ContentBlock::ToolUse { name, input, .. } => name.len() + input.to_string().len(),
24+
ContentBlock::ToolResult { content, .. } => content.len(),
25+
})
26+
.sum(),
27+
};
28+
// Add overhead for role/structure (~20 tokens)
29+
(chars / CHARS_PER_TOKEN) + 20
30+
}
31+
32+
/// Estimate the total token count of a conversation.
33+
pub fn estimate_conversation_tokens(conversation: &[Message]) -> usize {
34+
conversation.iter().map(estimate_message_tokens).sum()
35+
}
36+
37+
/// Extract text content from a message for summarization purposes.
38+
fn message_to_text(msg: &Message) -> String {
39+
match &msg.content {
40+
MessageContent::Text(s) => s.clone(),
41+
MessageContent::Blocks(blocks) => blocks
42+
.iter()
43+
.filter_map(|block| match block {
44+
ContentBlock::Text { text } => Some(text.as_str()),
45+
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
46+
ContentBlock::ToolResult { content, .. } => {
47+
// Truncate large tool results in the summary input
48+
if content.len() > 500 {
49+
None
50+
} else {
51+
Some(content.as_str())
52+
}
53+
}
54+
})
55+
.collect::<Vec<_>>()
56+
.join(" "),
57+
}
58+
}
59+
60+
/// Build a summarization prompt from the messages being compacted.
61+
fn build_summary_prompt(messages: &[Message]) -> String {
62+
let mut lines = Vec::new();
63+
for msg in messages {
64+
let role = match msg.role {
65+
Role::User => "User",
66+
Role::Assistant => "Assistant",
67+
};
68+
let text = message_to_text(msg);
69+
if !text.is_empty() {
70+
// Truncate extremely long messages in the summarization input
71+
let truncated = if text.len() > 2000 {
72+
format!("{}...", &text[..1997])
73+
} else {
74+
text
75+
};
76+
lines.push(format!("{}: {}", role, truncated));
77+
}
78+
}
79+
lines.join("\n")
80+
}
81+
82+
/// Compact a conversation by summarizing older messages.
83+
///
84+
/// If the conversation is under the token budget or too short, returns it unchanged.
85+
/// Otherwise, summarizes the oldest messages (keeping the most recent ones intact)
86+
/// and replaces them with a single summary message.
87+
pub async fn compact_if_needed(
88+
conversation: &mut Vec<Message>,
89+
provider: &dyn LmProvider,
90+
context_budget: usize,
91+
max_tokens: u32,
92+
) {
93+
let budget = if context_budget > 0 {
94+
context_budget
95+
} else {
96+
DEFAULT_CONTEXT_BUDGET
97+
};
98+
99+
// Don't compact small conversations
100+
if conversation.len() < MIN_MESSAGES_FOR_COMPACTION {
101+
return;
102+
}
103+
104+
let total_tokens = estimate_conversation_tokens(conversation);
105+
if total_tokens <= budget {
106+
return;
107+
}
108+
109+
tracing::info!(
110+
"Context compaction triggered: ~{} tokens (budget {}), {} messages",
111+
total_tokens,
112+
budget,
113+
conversation.len()
114+
);
115+
116+
// Split: older messages to summarize, recent messages to keep
117+
let keep_count = KEEP_RECENT.min(conversation.len());
118+
let split_at = conversation.len() - keep_count;
119+
120+
if split_at < 2 {
121+
// Not enough old messages to summarize — just trim
122+
let drain_count = conversation.len().saturating_sub(keep_count);
123+
conversation.drain(..drain_count);
124+
return;
125+
}
126+
127+
let old_messages = &conversation[..split_at];
128+
let summary_input = build_summary_prompt(old_messages);
129+
130+
let summarize_prompt = format!(
131+
"Summarize this conversation concisely, preserving key decisions, code context, \
132+
task state, and important details. Focus on what matters for continuing the \
133+
conversation. Be direct — no preamble.\n\n{}",
134+
summary_input
135+
);
136+
137+
let summary_messages = vec![Message {
138+
role: Role::User,
139+
content: MessageContent::Text(summarize_prompt),
140+
}];
141+
142+
// Use the same provider to generate the summary
143+
let summary_text = match provider
144+
.invoke(
145+
"You are a concise summarizer. Output only the summary.",
146+
&summary_messages,
147+
max_tokens.min(2048),
148+
None,
149+
)
150+
.await
151+
{
152+
Ok(result) => result.text(),
153+
Err(e) => {
154+
tracing::warn!(
155+
"Context compaction failed: {}. Falling back to simple trim.",
156+
e
157+
);
158+
// Fall back to simple trim
159+
conversation.drain(..split_at);
160+
return;
161+
}
162+
};
163+
164+
// Replace old messages with the summary
165+
conversation.drain(..split_at);
166+
conversation.insert(
167+
0,
168+
Message {
169+
role: Role::User,
170+
content: MessageContent::Text(format!(
171+
"[Context summary of earlier conversation]\n{}",
172+
summary_text
173+
)),
174+
},
175+
);
176+
177+
let new_tokens = estimate_conversation_tokens(conversation);
178+
tracing::info!(
179+
"Compacted {} messages into summary. ~{} → ~{} tokens",
180+
split_at,
181+
total_tokens,
182+
new_tokens
183+
);
184+
}

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ mod chat;
44
mod claude_provider;
55
mod cli;
66
mod config;
7+
mod context;
78
mod events;
89
mod init;
10+
mod pidfile;
911
mod plugins;
1012
mod scheduler;
1113
mod server;

src/pidfile.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
const PID_FILENAME: &str = ".pulse-null.pid";
5+
6+
/// Get the PID file path for an entity root directory.
7+
pub fn path(root_dir: &Path) -> PathBuf {
8+
root_dir.join(PID_FILENAME)
9+
}
10+
11+
/// Write the current process PID to the PID file.
12+
pub fn write(root_dir: &Path) -> std::io::Result<()> {
13+
let pid = std::process::id();
14+
fs::write(path(root_dir), pid.to_string())
15+
}
16+
17+
/// Read the PID from the PID file, if it exists and is valid.
18+
pub fn read(root_dir: &Path) -> Option<u32> {
19+
fs::read_to_string(path(root_dir))
20+
.ok()
21+
.and_then(|s| s.trim().parse().ok())
22+
}
23+
24+
/// Remove the PID file.
25+
pub fn remove(root_dir: &Path) {
26+
let _ = fs::remove_file(path(root_dir));
27+
}
28+
29+
/// Check if a process with the given PID is still alive.
30+
pub fn is_alive(pid: u32) -> bool {
31+
Path::new(&format!("/proc/{}", pid)).exists()
32+
}
33+
34+
/// Send SIGTERM to the given PID. Returns true if the signal was sent.
35+
pub fn kill(pid: u32) -> bool {
36+
// SAFETY: sending SIGTERM to a process ID is a standard Unix operation
37+
unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 }
38+
}

src/plugins/manager.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ mod tests {
200200
api_key: None,
201201
model: "test".into(),
202202
max_tokens: 1024,
203+
context_budget: 0,
203204
},
204205
security: SecurityConfig {
205206
secret: None,

0 commit comments

Comments
 (0)