use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use lingshu_types::{Platform, ToolSchema};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct ContextEngineSessionCtx {
pub session_id: String,
pub lingshu_home: PathBuf,
pub platform: Platform,
pub model: String,
pub context_length: usize,
}
pub const MAX_ENGINE_TOOLS: usize = 20;
#[async_trait]
pub trait ContextEngine: Send + Sync + 'static {
fn name(&self) -> &str;
fn context_length(&self) -> usize;
fn threshold_tokens(&self) -> usize;
fn get_tool_schemas(&self) -> Vec<ToolSchema> {
vec![]
}
async fn handle_tool_call(
&self,
_name: &str,
_args: serde_json::Value,
) -> Option<anyhow::Result<String>> {
None
}
async fn on_session_start(&self, ctx: ContextEngineSessionCtx) -> anyhow::Result<()>;
async fn on_session_end(&self, session_id: &str) -> anyhow::Result<()>;
async fn on_session_reset(&self) -> anyhow::Result<()>;
fn is_available(&self) -> bool;
}
pub struct BuiltinCompressorEngine {
ctx_length: usize,
threshold: f64,
}
impl BuiltinCompressorEngine {
pub fn new(context_length: usize, threshold: f64) -> Self {
Self {
ctx_length: context_length,
threshold: threshold.clamp(0.1, 0.95),
}
}
}
impl Default for BuiltinCompressorEngine {
fn default() -> Self {
Self::new(128_000, 0.50)
}
}
#[async_trait]
impl ContextEngine for BuiltinCompressorEngine {
fn name(&self) -> &str {
"compressor"
}
fn context_length(&self) -> usize {
self.ctx_length
}
fn threshold_tokens(&self) -> usize {
(self.ctx_length as f64 * self.threshold) as usize
}
async fn on_session_start(&self, _ctx: ContextEngineSessionCtx) -> anyhow::Result<()> {
Ok(())
}
async fn on_session_end(&self, _session_id: &str) -> anyhow::Result<()> {
Ok(())
}
async fn on_session_reset(&self) -> anyhow::Result<()> {
Ok(())
}
fn is_available(&self) -> bool {
true
}
}
struct PluginProcess {
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
next_id: u64,
}
impl PluginProcess {
async fn call(
&mut self,
method: &str,
params: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let id = self.next_id;
self.next_id += 1;
let req =
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
let line = serde_json::to_string(&req)? + "\n";
self.stdin.write_all(line.as_bytes()).await?;
self.stdin.flush().await?;
let mut buf = String::new();
loop {
buf.clear();
let n = self.stdout.read_line(&mut buf).await?;
if n == 0 {
anyhow::bail!("plugin process closed stdout unexpectedly");
}
let trimmed = buf.trim();
if trimmed.is_empty() {
continue;
}
let resp: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue, };
if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
if let Some(err) = resp.get("error") {
anyhow::bail!("plugin JSON-RPC error: {err}");
}
return Ok(resp
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null));
}
}
}
}
pub struct PluginContextEngine {
engine_name: String,
command: String,
#[allow(dead_code)]
args: Vec<String>,
ctx_length: usize,
threshold: f64,
cached_schemas: Vec<ToolSchema>,
proc: Arc<Mutex<Option<PluginProcess>>>,
}
impl PluginContextEngine {
pub async fn start(
engine_name: String,
command: String,
args: Vec<String>,
ctx_length: usize,
threshold: f64,
) -> anyhow::Result<Self> {
let mut child = Command::new(&command)
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit()) .spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn context engine '{engine_name}': {e}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("plugin stdin unavailable"))?;
let stdout = BufReader::new(
child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("plugin stdout unavailable"))?,
);
let mut proc = PluginProcess {
stdin,
stdout,
next_id: 0,
};
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
proc.call("get_tool_schemas", serde_json::json!({})),
)
.await
.map_err(|_| anyhow::anyhow!("timeout fetching tool schemas from '{engine_name}'"))??;
let cached_schemas: Vec<ToolSchema> = if result.is_null() {
vec![]
} else {
serde_json::from_value(result).unwrap_or_default()
};
Ok(Self {
engine_name,
command,
args,
ctx_length,
threshold: threshold.clamp(0.1, 0.95),
cached_schemas,
proc: Arc::new(Mutex::new(Some(proc))),
})
}
async fn rpc_call(
&self,
method: &str,
params: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let mut guard = self.proc.lock().await;
let proc = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("plugin process has exited"))?;
tokio::time::timeout(
std::time::Duration::from_secs(30),
proc.call(method, params),
)
.await
.map_err(|_| anyhow::anyhow!("timeout on '{method}'"))?
}
}
#[async_trait]
impl ContextEngine for PluginContextEngine {
fn name(&self) -> &str {
&self.engine_name
}
fn context_length(&self) -> usize {
self.ctx_length
}
fn threshold_tokens(&self) -> usize {
(self.ctx_length as f64 * self.threshold) as usize
}
fn get_tool_schemas(&self) -> Vec<ToolSchema> {
self.cached_schemas.clone()
}
async fn handle_tool_call(
&self,
name: &str,
args: serde_json::Value,
) -> Option<anyhow::Result<String>> {
let result = self
.rpc_call(
"handle_tool_call",
serde_json::json!({"name": name, "args": args}),
)
.await;
match result {
Ok(v) => Some(Ok(v
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| v.to_string()))),
Err(e) => Some(Err(e)),
}
}
async fn on_session_start(&self, ctx: ContextEngineSessionCtx) -> anyhow::Result<()> {
self.rpc_call(
"on_session_start",
serde_json::json!({
"session_id": ctx.session_id,
"lingshu_home": ctx.lingshu_home.to_string_lossy(),
"platform": ctx.platform.to_string(),
"model": ctx.model,
"context_length": ctx.context_length,
}),
)
.await?;
Ok(())
}
async fn on_session_end(&self, session_id: &str) -> anyhow::Result<()> {
let _ = self
.rpc_call(
"on_session_end",
serde_json::json!({"session_id": session_id}),
)
.await;
let mut guard = self.proc.lock().await;
*guard = None; Ok(())
}
async fn on_session_reset(&self) -> anyhow::Result<()> {
let _ = self
.rpc_call("on_session_reset", serde_json::json!({}))
.await;
Ok(())
}
fn is_available(&self) -> bool {
std::process::Command::new(&self.command)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|_| true)
.unwrap_or(false)
}
}
pub async fn load_context_engine(
engine_name: Option<&str>,
ctx_length: usize,
threshold: f64,
) -> Arc<dyn ContextEngine> {
let name = match engine_name {
None | Some("compressor") | Some("builtin") => {
return Arc::new(BuiltinCompressorEngine::new(ctx_length, threshold));
}
Some(n) => n,
};
match lingshu_plugins::find_context_engine_manifest(name) {
None => {
tracing::warn!(
engine = %name,
"context engine plugin not found — falling back to builtin compressor"
);
Arc::new(BuiltinCompressorEngine::new(ctx_length, threshold))
}
Some(manifest) => {
match PluginContextEngine::start(
manifest.name,
manifest.command,
manifest.args,
ctx_length,
threshold,
)
.await
{
Ok(engine) => {
tracing::info!(engine = %name, "context engine plugin loaded");
Arc::new(engine)
}
Err(e) => {
tracing::warn!(
engine = %name,
error = %e,
"failed to start context engine plugin — falling back to builtin compressor"
);
Arc::new(BuiltinCompressorEngine::new(ctx_length, threshold))
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn builtin_engine_defaults() {
let engine = BuiltinCompressorEngine::default();
assert_eq!(engine.name(), "compressor");
assert_eq!(engine.context_length(), 128_000);
assert_eq!(engine.threshold_tokens(), 64_000);
assert!(engine.is_available());
assert!(engine.get_tool_schemas().is_empty());
}
#[test]
fn builtin_engine_custom() {
let engine = BuiltinCompressorEngine::new(200_000, 0.70);
assert_eq!(engine.context_length(), 200_000);
assert_eq!(engine.threshold_tokens(), 140_000);
}
#[test]
fn threshold_clamped() {
let engine = BuiltinCompressorEngine::new(100_000, 1.5);
assert_eq!(engine.threshold_tokens(), 95_000);
let engine = BuiltinCompressorEngine::new(100_000, 0.01);
assert_eq!(engine.threshold_tokens(), 10_000);
}
#[tokio::test]
async fn lifecycle_methods_succeed() {
let engine = BuiltinCompressorEngine::default();
let ctx = ContextEngineSessionCtx {
session_id: "test-123".into(),
lingshu_home: std::path::PathBuf::from("/tmp/test"),
platform: Platform::Cli,
model: "test/model".into(),
context_length: 128_000,
};
engine.on_session_start(ctx).await.unwrap();
engine.on_session_reset().await.unwrap();
engine.on_session_end("test-123").await.unwrap();
}
#[tokio::test]
async fn handle_tool_call_default_returns_none() {
let engine = BuiltinCompressorEngine::default();
let result = engine
.handle_tool_call("any_tool", serde_json::json!({}))
.await;
assert!(result.is_none(), "builtin engine must not handle any tools");
}
#[test]
fn max_engine_tools_limit() {
assert_eq!(MAX_ENGINE_TOOLS, 20);
}
#[tokio::test]
async fn load_context_engine_builtin_names() {
for name in &[None, Some("compressor"), Some("builtin")] {
let engine = load_context_engine(*name, 128_000, 0.5).await;
assert_eq!(engine.name(), "compressor");
}
}
#[tokio::test]
async fn load_context_engine_unknown_falls_back() {
let engine = load_context_engine(Some("__no_such_engine__"), 64_000, 0.6).await;
assert_eq!(engine.name(), "compressor");
assert_eq!(engine.context_length(), 64_000);
}
}