use std::io::Read;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::Utc;
use clap::{Parser, ValueEnum};
use deepseek::agent::builtin_tools::{default_tools_with_scheduler, default_tools};
use deepseek::agent::scheduler::{
maintenance, CronExpr, Schedule, Scheduler, DEFAULT_MAX_TASKS,
};
use deepseek::types::EffortLevel;
use deepseek::ReqwestClient;
use deepseek::{PermissionMode, RunOptions};
use futures::StreamExt;
#[derive(Parser, Debug)]
#[command(name = "deepseek-loop", about = "DeepSeek agent loop — Claude-Code shape")]
struct Args {
#[arg(trailing_var_arg = true)]
prompt: Vec<String>,
#[arg(short, long, default_value = "deepseek-v4-pro")]
model: String,
#[arg(long)]
max_turns: Option<u32>,
#[arg(long)]
max_budget_usd: Option<f64>,
#[arg(long, value_enum, default_value_t = CliEffort::High)]
effort: CliEffort,
#[arg(long, value_enum, default_value_t = CliPermissionMode::AcceptEdits)]
permission_mode: CliPermissionMode,
#[arg(long, value_delimiter = ',')]
allowed_tools: Option<Vec<String>>,
#[arg(long, value_delimiter = ',', default_value = "")]
disallowed_tools: Vec<String>,
#[arg(long)]
system_prompt: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
r#loop: bool,
#[arg(long)]
loop_every: Option<String>,
#[arg(long)]
loop_prompt: Option<String>,
#[arg(long)]
loop_max_iterations: Option<u32>,
#[arg(long)]
resume: Option<String>,
#[arg(long, default_value_t = DEFAULT_MAX_TASKS)]
max_tasks: usize,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum CliEffort {
Low,
Medium,
High,
Max,
}
impl From<CliEffort> for EffortLevel {
fn from(v: CliEffort) -> Self {
match v {
CliEffort::Low => EffortLevel::Low,
CliEffort::Medium => EffortLevel::Medium,
CliEffort::High => EffortLevel::High,
CliEffort::Max => EffortLevel::Max,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum CliPermissionMode {
Default,
AcceptEdits,
Plan,
DontAsk,
BypassPermissions,
}
impl From<CliPermissionMode> for PermissionMode {
fn from(v: CliPermissionMode) -> Self {
match v {
CliPermissionMode::Default => PermissionMode::Default,
CliPermissionMode::AcceptEdits => PermissionMode::AcceptEdits,
CliPermissionMode::Plan => PermissionMode::Plan,
CliPermissionMode::DontAsk => PermissionMode::DontAsk,
CliPermissionMode::BypassPermissions => PermissionMode::BypassPermissions,
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local").or_else(|_| dotenvy::dotenv());
let _ = dotenvy::from_filename_override(".env.local");
let args = Args::parse();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,deepseek=info")),
)
.with_writer(std::io::stderr)
.init();
let api_key = args
.api_key
.clone()
.or_else(|| std::env::var("DEEPSEEK_API_KEY").ok())
.ok_or_else(|| anyhow::anyhow!("DEEPSEEK_API_KEY not set and --api-key not provided"))?;
let session_id = args
.resume
.clone()
.unwrap_or_else(|| format!("cli-{}", uuid_short()));
let mut scheduler = if args.resume.is_some() {
Scheduler::restore(&session_id)
} else {
Scheduler::with_cap(&session_id, args.max_tasks)
};
if args.max_tasks != DEFAULT_MAX_TASKS && !scheduler.is_disabled() {
let mut s = Scheduler::with_cap(&session_id, args.max_tasks);
for t in scheduler.list().into_iter().cloned().collect::<Vec<_>>() {
let _ = s.create(t.schedule.clone(), t.prompt.clone(), t.recurring);
}
scheduler = s;
}
let scheduler = Arc::new(Mutex::new(scheduler));
let opts = build_opts(&args);
let http = ReqwestClient::new();
let tools_arc: Arc<Vec<Box<dyn deepseek::Tool>>> = if scheduler.lock().unwrap().is_disabled() {
Arc::new(default_tools())
} else {
Arc::new(default_tools_with_scheduler(scheduler.clone()))
};
if let Some(interval) = args.loop_every.as_deref() {
let cron_expr = interval_to_cron(interval)?;
let prompt = resolve_loop_prompt(&args)?;
let cap = args.loop_max_iterations;
return run_fixed_interval_loop(
scheduler,
cron_expr,
prompt,
tools_arc,
http,
api_key,
opts,
cap,
)
.await;
}
if args.r#loop {
let prompt = resolve_loop_prompt(&args)?;
let cap = args.loop_max_iterations;
return run_dynamic_loop(prompt, tools_arc, http, api_key, opts, cap).await;
}
let prompt = if args.prompt.is_empty() {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf.trim().to_string()
} else {
args.prompt.join(" ")
};
if prompt.is_empty() {
anyhow::bail!("empty prompt; pass it as positional args or via stdin");
}
stream_one_run(prompt, tools_arc, http, api_key, opts).await
}
fn build_opts(args: &Args) -> RunOptions {
let mut opts = RunOptions::new(&args.model)
.effort(args.effort.into())
.permission_mode(args.permission_mode.into());
if let Some(n) = args.max_turns {
opts = opts.max_turns(n);
}
if let Some(b) = args.max_budget_usd {
opts = opts.max_budget_usd(b);
}
if let Some(allowed) = args.allowed_tools.clone() {
opts = opts.allowed_tools(allowed);
}
if !args.disallowed_tools.is_empty() {
opts = opts.disallowed_tools(args.disallowed_tools.clone());
}
if let Some(sp) = args.system_prompt.clone() {
opts = opts.system_prompt(sp);
} else {
opts = opts.system_prompt(default_system_prompt());
}
if let Some(b) = args.base_url.clone() {
opts = opts.base_url(b);
}
opts
}
fn default_system_prompt() -> String {
"You are a coding agent running in a developer's terminal. Use the available tools \
(Read, Write, Edit, Glob, Grep, Bash, CronCreate, CronList, CronDelete, Monitor) \
to inspect, modify, and schedule work in the working directory. Be concise; \
explain only when asked."
.into()
}
fn resolve_loop_prompt(args: &Args) -> anyhow::Result<String> {
if let Some(p) = args.loop_prompt.clone() {
return Ok(p);
}
if !args.prompt.is_empty() {
return Ok(args.prompt.join(" "));
}
Ok(maintenance::resolve_prompt())
}
fn interval_to_cron(input: &str) -> anyhow::Result<CronExpr> {
let s = input.trim();
if s.is_empty() {
anyhow::bail!("--loop-every: empty interval");
}
let (num_part, unit) = s.split_at(s.len().saturating_sub(1));
let n: u64 = num_part
.parse()
.map_err(|e| anyhow::anyhow!("--loop-every: invalid number '{num_part}': {e}"))?;
if n == 0 {
anyhow::bail!("--loop-every: interval must be > 0");
}
let cron = match unit {
"s" => {
tracing::warn!(
"--loop-every: sub-minute interval {n}s rounded up to 1 minute (cron granularity)"
);
"*/1 * * * *".to_string()
}
"m" => {
if n > 60 {
anyhow::bail!("--loop-every: minute intervals must be ≤ 60");
}
let step = pick_cron_step(n, 60);
format!("*/{step} * * * *")
}
"h" => {
if n > 24 {
anyhow::bail!("--loop-every: hour intervals must be ≤ 24");
}
let step = pick_cron_step(n, 24);
format!("0 */{step} * * *")
}
"d" => {
format!("0 0 */{n} * *")
}
other => anyhow::bail!("--loop-every: unsupported unit '{other}'; use s|m|h|d"),
};
CronExpr::parse(&cron).map_err(|e| anyhow::anyhow!("--loop-every: {e}"))
}
fn pick_cron_step(n: u64, base: u64) -> u64 {
let mut best = n;
let mut best_diff = u64::MAX;
for d in 1..=base {
if base.is_multiple_of(d) {
let diff = d.abs_diff(n);
if diff < best_diff {
best_diff = diff;
best = d;
}
}
}
best
}
#[allow(clippy::too_many_arguments)] async fn run_fixed_interval_loop(
scheduler: Arc<Mutex<Scheduler>>,
cron: CronExpr,
prompt: String,
tools: Arc<Vec<Box<dyn deepseek::Tool>>>,
http: ReqwestClient,
api_key: String,
opts: RunOptions,
max_iterations: Option<u32>,
) -> anyhow::Result<()> {
let task_id = {
let mut s = scheduler.lock().unwrap();
s.create(Schedule::Cron(Box::new(cron)), prompt.clone(), true)
.map_err(|e| anyhow::anyhow!("scheduler: {e}"))?
};
eprintln!("/loop registered task_id={} prompt={prompt:?}", task_id.as_str());
let mut iter_count: u32 = 0;
loop {
let due = {
let mut s = scheduler.lock().unwrap();
s.tick(Utc::now())
};
for fire in due {
stream_one_run(
fire.prompt.clone(),
tools.clone(),
http.clone(),
api_key.clone(),
opts.clone(),
)
.await?;
iter_count += 1;
if let Some(cap) = max_iterations {
if iter_count >= cap {
return Ok(());
}
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn run_dynamic_loop(
prompt: String,
tools: Arc<Vec<Box<dyn deepseek::Tool>>>,
http: ReqwestClient,
api_key: String,
opts: RunOptions,
max_iterations: Option<u32>,
) -> anyhow::Result<()> {
eprintln!("/loop dynamic mode — prompt={prompt:?}");
let mut iter_count: u32 = 0;
loop {
stream_one_run(
prompt.clone(),
tools.clone(),
http.clone(),
api_key.clone(),
opts.clone(),
)
.await?;
iter_count += 1;
if let Some(cap) = max_iterations {
if iter_count >= cap {
return Ok(());
}
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
async fn stream_one_run(
prompt: String,
tools: Arc<Vec<Box<dyn deepseek::Tool>>>,
http: ReqwestClient,
api_key: String,
opts: RunOptions,
) -> anyhow::Result<()> {
let mut stream = Box::pin(deepseek::run(http, api_key, tools, prompt, opts));
while let Some(msg) = stream.next().await {
println!("{}", serde_json::to_string(&msg)?);
if msg.is_terminal() {
break;
}
}
Ok(())
}
fn uuid_short() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:x}")
}