- Added module-level documentation for memory tools (`remember`, `recall`, `forget`) to clarify their purpose. - Improved documentation in `recall.rs` and `remember.rs` to describe the functionality and flow of memory entry operations. - Updated `mod.rs` to include descriptions for the tool trait and execution context. - Enhanced `plan.rs` with detailed comments on plan-mode signaling tools. - Documented text search tools in `search.rs` to explain their functionality. - Improved sequential-thinking tool documentation in `seqthink.rs`. - Added safety filter documentation in `shell_filter` for credential and git operations. - Enhanced utility tools documentation, including `cd`, `dir_cache_update`, and `todowrite`. - Improved rendering documentation in view modules (`chat`, `markdown`, `status`, `workflow`) to clarify rendering flows and purposes.
1154 lines
47 KiB
Rust
1154 lines
47 KiB
Rust
//! The `Action` enum and its single dispatcher, `apply_action` — the
|
|
//! chokepoint through which every key input, streaming event, and async
|
|
//! background-thread result mutates `AppStateRest`.
|
|
//!
|
|
//! Flow: controllers/subagent threads construct `Action` values → the event
|
|
//! loop calls `apply_action(&mut state, action)` → for turn-producing
|
|
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
|
|
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
|
|
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
|
|
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
|
|
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
|
|
//! usage counters).
|
|
//!
|
|
//! Why: keeping all state mutation behind one function means callers only
|
|
//! need to know how to *produce* actions, not how to update state safely;
|
|
//! running turns on plain OS threads (rather than blocking the main loop)
|
|
//! keeps the TUI responsive while the LLM streams.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::app::harness::Verdict;
|
|
use sha2::Digest;
|
|
use crate::app::mode::ModeKind;
|
|
use crate::app::review::{should_trigger_review, trigger_review};
|
|
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
|
use crate::app::state::runtime::TurnEvent;
|
|
use crate::app::state::types::{Origin, Overlay, Toast, ToastKind};
|
|
use crate::dto::chat::message::{ChatMessage, Role};
|
|
|
|
/// A single, well-typed event in the app — produced by key input, the
|
|
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
|
|
/// when applied via `apply_action`.
|
|
///
|
|
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can
|
|
/// continue across as many turns as needed. Each iteration still honours
|
|
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
|
|
/// observable and cancellable from the UI.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Action {
|
|
ForceQuit,
|
|
SwitchMode(ModeKind),
|
|
SubmitInput(String),
|
|
InsertChar(char),
|
|
DeleteChar,
|
|
DeleteCharRight,
|
|
CursorLeft,
|
|
CursorRight,
|
|
HistoryUp,
|
|
HistoryDown,
|
|
ScrollUp,
|
|
ScrollDown,
|
|
OpenOverlay(Overlay),
|
|
CloseOverlay,
|
|
SystemNote {
|
|
kind: String,
|
|
message: String,
|
|
},
|
|
QuitConfirm,
|
|
Resize(u16, u16),
|
|
Tick,
|
|
LessonExport {
|
|
path: String,
|
|
},
|
|
LessonImport {
|
|
path: String,
|
|
},
|
|
LessonAccept {
|
|
name: String,
|
|
},
|
|
LessonReject {
|
|
name: String,
|
|
},
|
|
StartOAuth {
|
|
provider: String,
|
|
},
|
|
OpenEditor {
|
|
path: String,
|
|
},
|
|
McpAdd {
|
|
name: String,
|
|
command: String,
|
|
},
|
|
ModelList,
|
|
AbortTurn,
|
|
}
|
|
|
|
/// Apply an `Action` to the application state.
|
|
///
|
|
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll
|
|
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) →
|
|
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs
|
|
/// (staleness sweep, pending-lesson commit).
|
|
///
|
|
/// Why: the single chokepoint that turns every typed key and async event
|
|
/// into a state change, so callers (controllers, subagent threads) only
|
|
/// need to know how to *produce* actions.
|
|
///
|
|
/// Return: nothing; `state` is mutated in place.
|
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|
match action {
|
|
Action::ForceQuit => {
|
|
save_current_session(state);
|
|
state.quit = true;
|
|
}
|
|
Action::SwitchMode(mode) => {
|
|
state.misc.overlay = match mode {
|
|
ModeKind::Chat
|
|
| ModeKind::Bash
|
|
| ModeKind::Workflow => Overlay::None,
|
|
ModeKind::Help => Overlay::Help,
|
|
ModeKind::Settings => Overlay::Settings,
|
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
|
|
|
ModeKind::KeyInput => Overlay::KeyInput,
|
|
ModeKind::Editor => Overlay::Editor,
|
|
ModeKind::Effort => Overlay::Effort,
|
|
ModeKind::Mcp => Overlay::Mcp,
|
|
ModeKind::Todo => Overlay::Todo,
|
|
ModeKind::Rewind => Overlay::Rewind,
|
|
ModeKind::Loading => Overlay::Loading,
|
|
};
|
|
state.dirty = true;
|
|
}
|
|
Action::SubmitInput(text) => {
|
|
state.input.submit();
|
|
let text = text.trim().to_string();
|
|
if text.is_empty() {
|
|
state.dirty = true;
|
|
return;
|
|
}
|
|
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(ChatMessage::user(text));
|
|
}
|
|
state.misc.thinking = true;
|
|
spawn_turn(state);
|
|
state.dirty = true;
|
|
}
|
|
Action::InsertChar(c) => {
|
|
state.input.insert(c);
|
|
state.dirty = true;
|
|
}
|
|
Action::DeleteChar => {
|
|
state.input.delete_left();
|
|
state.dirty = true;
|
|
}
|
|
Action::DeleteCharRight => {
|
|
state.input.delete_right();
|
|
state.dirty = true;
|
|
}
|
|
Action::CursorLeft => {
|
|
state.input.char_left();
|
|
}
|
|
Action::CursorRight => {
|
|
state.input.char_right();
|
|
}
|
|
Action::HistoryUp => {
|
|
state.input.history_up();
|
|
state.dirty = true;
|
|
}
|
|
Action::HistoryDown => {
|
|
state.input.history_down();
|
|
state.dirty = true;
|
|
}
|
|
Action::ScrollUp => {
|
|
state.scroll.scroll_up(5);
|
|
state.dirty = true;
|
|
}
|
|
Action::ScrollDown => {
|
|
state.scroll.scroll_down(5);
|
|
state.dirty = true;
|
|
}
|
|
Action::OpenOverlay(overlay) => {
|
|
state.misc.overlay = overlay;
|
|
state.dirty = true;
|
|
}
|
|
Action::OpenEditor { path } => {
|
|
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
|
|
match resolved {
|
|
Ok(abs_path) => {
|
|
let content = std::fs::read_to_string(&abs_path)
|
|
.unwrap_or_default();
|
|
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
|
|
let ed = crate::app::mode::editor::EditorState::open(
|
|
abs_path.to_string_lossy().to_string(),
|
|
Some(lines),
|
|
);
|
|
state.misc.editor = Some(ed);
|
|
state.misc.overlay = Overlay::Editor;
|
|
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path)));
|
|
}
|
|
Err(e) => {
|
|
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e)));
|
|
}
|
|
}
|
|
state.dirty = true;
|
|
}
|
|
Action::McpAdd { name, command } => {
|
|
let extra_args: Vec<String> = command.split_whitespace().map(|s| s.to_string()).collect();
|
|
let cmd = extra_args.first().cloned().unwrap_or_default();
|
|
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
|
|
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
|
|
Ok(_) => {
|
|
let tool_count = state.mcp_manager.servers.last()
|
|
.map(|s| s.tools.len())
|
|
.unwrap_or(0);
|
|
state.push_toast(Toast::new(ToastKind::Success,
|
|
format!("Connected MCP server '{}' ({} tools)", name, tool_count)));
|
|
state.dirty = true;
|
|
}
|
|
Err(e) => {
|
|
state.push_toast(Toast::new(ToastKind::Error,
|
|
format!("MCP connect failed: {}", e)));
|
|
}
|
|
}
|
|
}
|
|
Action::ModelList => {
|
|
state.misc.selected_index = 0;
|
|
state.misc.overlay = Overlay::ModelSelector;
|
|
state.dirty = true;
|
|
}
|
|
Action::CloseOverlay => {
|
|
// If the overlay is the Editor, dismiss it properly first
|
|
if state.misc.overlay == Overlay::Editor {
|
|
crate::app::mode::editor::handle_editor_dismiss(state);
|
|
}
|
|
state.misc.overlay = Overlay::None;
|
|
state.dirty = true;
|
|
}
|
|
Action::SystemNote { kind: _kind, message } => {
|
|
let toast = crate::app::state::types::Toast::new(
|
|
crate::app::state::types::ToastKind::Info,
|
|
message,
|
|
);
|
|
state.push_toast(toast);
|
|
}
|
|
Action::QuitConfirm => {
|
|
state.misc.overlay = Overlay::QuitConfirm;
|
|
state.dirty = true;
|
|
}
|
|
Action::Resize(w, _h) => {
|
|
state.scroll.set_max_visible(w as usize);
|
|
state.dirty = true;
|
|
}
|
|
Action::LessonExport { path } => {
|
|
let dest = std::path::Path::new(&path);
|
|
if let Some(parent) = dest.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
match crate::model::memory::export_lessons(&state.memory_dir, dest) {
|
|
Ok(_) => {
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
crate::app::state::types::ToastKind::Success,
|
|
format!("lessons exported to {}", path),
|
|
));
|
|
}
|
|
Err(e) => {
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
crate::app::state::types::ToastKind::Error,
|
|
format!("export failed: {}", e),
|
|
));
|
|
}
|
|
}
|
|
state.dirty = true;
|
|
}
|
|
Action::LessonImport { path } => {
|
|
let src = std::path::Path::new(&path);
|
|
match crate::model::memory::import_lessons(&state.memory_dir, src) {
|
|
Ok(count) => {
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
crate::app::state::types::ToastKind::Success,
|
|
format!("imported {} lessons from {}", count, path),
|
|
));
|
|
}
|
|
Err(e) => {
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
crate::app::state::types::ToastKind::Error,
|
|
format!("import failed: {}", e),
|
|
));
|
|
}
|
|
}
|
|
state.dirty = true;
|
|
}
|
|
Action::StartOAuth { provider } => {
|
|
let turn_events = state.turn_events.clone();
|
|
let provider_clone = provider.clone();
|
|
std::thread::spawn(move || {
|
|
let result = run_oauth_flow(&provider_clone);
|
|
let message = match result {
|
|
Ok(msg) => msg,
|
|
Err(e) => format!("OAuth login failed: {}", e),
|
|
};
|
|
if let Ok(mut q) = turn_events.lock() {
|
|
q.push_back(TurnEvent::SystemNote {
|
|
kind: "oauth".to_string(),
|
|
message,
|
|
});
|
|
}
|
|
});
|
|
let toast = Toast::new(ToastKind::Info, format!("Opening browser for {} login...", provider));
|
|
state.push_toast(toast);
|
|
state.dirty = true;
|
|
}
|
|
Action::Tick => {
|
|
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
state.misc.drain_expired_toasts(now_ms);
|
|
crate::app::review::maybe_run_staleness_sweep(state);
|
|
if let Some(ref rt) = state.session_runtime {
|
|
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
|
|
}
|
|
|
|
let events: Vec<TurnEvent> = {
|
|
if let Ok(mut q) = state.turn_events.lock() {
|
|
q.drain(..).collect()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
};
|
|
let mut turn_finished = false;
|
|
for event in events {
|
|
match event {
|
|
TurnEvent::AssistantMessage(msg) => {
|
|
state.misc.thinking = false;
|
|
state.misc.api_connected = true;
|
|
let display_content = msg.content.clone().unwrap_or_default();
|
|
if !display_content.is_empty() {
|
|
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content));
|
|
}
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(msg);
|
|
}
|
|
}
|
|
TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error, path } => {
|
|
state.misc.thinking = false;
|
|
let display_path = path.unwrap_or_default();
|
|
let display = if tool_name == "read" {
|
|
let line_count = output.lines().count();
|
|
if !display_path.is_empty() {
|
|
format!("read: {} ({} lines)", display_path, line_count)
|
|
} else {
|
|
format!("read: {} line(s)", line_count)
|
|
}
|
|
} else {
|
|
format!("{}: {}", tool_name, output)
|
|
};
|
|
state.push_transcript(ChatMessageDisplay::new(
|
|
Role::Tool,
|
|
display,
|
|
));
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
|
rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult {
|
|
tool_call_id,
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
duration_ms: 0,
|
|
});
|
|
}
|
|
}
|
|
TurnEvent::SystemNote { kind, message } => {
|
|
if kind == "edits" {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
if let Ok(count) = message.parse::<u32>() {
|
|
rt.edit_count += count;
|
|
}
|
|
}
|
|
if should_trigger_review(state, Origin::Main) {
|
|
let _ = trigger_review(state);
|
|
}
|
|
} else if kind == "review" {
|
|
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
|
|
message.rsplit(' ').next().and_then(|w| {
|
|
w.trim_end_matches(')').trim_end_matches('s')
|
|
.split('(').next_back()
|
|
.and_then(|n| n.parse::<u32>().ok())
|
|
}).unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
if lessons_found > 0 {
|
|
rt.consecutive_empty_reviews = 0;
|
|
rt.lesson_count += lessons_found;
|
|
} else {
|
|
rt.consecutive_empty_reviews += 1;
|
|
}
|
|
}
|
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
|
} else if kind == "task_retry" {
|
|
state.push_transcript(ChatMessageDisplay::new(
|
|
crate::dto::chat::message::Role::System,
|
|
message.clone(),
|
|
));
|
|
state.push_toast(Toast::new(ToastKind::Info, "Auto-continuing unfinished tasks...".to_string()));
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(crate::dto::chat::message::ChatMessage::system(message.clone()));
|
|
}
|
|
} else {
|
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
|
}
|
|
}
|
|
TurnEvent::StreamStart => {
|
|
state.misc.thinking = false;
|
|
state.misc.api_connected = true;
|
|
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
|
|
}
|
|
TurnEvent::StreamToken(delta) => {
|
|
if let Some(last) = state.transcript_cache.messages.last_mut() {
|
|
if last.role == Role::Assistant {
|
|
last.content.push_str(&delta);
|
|
state.transcript_cache.dirty = true;
|
|
}
|
|
}
|
|
}
|
|
TurnEvent::StreamDone(msg) => {
|
|
state.misc.thinking = false;
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.push_message(msg);
|
|
}
|
|
}
|
|
TurnEvent::Usage { tokens_in, tokens_out } => {
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
rt.usage.tokens_in += tokens_in;
|
|
rt.usage.tokens_out += tokens_out;
|
|
rt.usage.api_calls += 1;
|
|
}
|
|
}
|
|
TurnEvent::Error(msg) => {
|
|
state.misc.api_connected = false;
|
|
let long_toast = Toast {
|
|
kind: ToastKind::Error,
|
|
message: msg.clone(),
|
|
created_at: chrono::Utc::now().timestamp_millis(),
|
|
lifetime_ms: 15000,
|
|
};
|
|
state.push_toast(long_toast);
|
|
state.push_transcript(ChatMessageDisplay::new(
|
|
crate::dto::chat::message::Role::System,
|
|
format!("Error: {}", msg),
|
|
));
|
|
turn_finished = true;
|
|
}
|
|
TurnEvent::Done => {
|
|
state.misc.thinking = false;
|
|
turn_finished = true;
|
|
}
|
|
}
|
|
}
|
|
if turn_finished {
|
|
maybe_trigger_review(state);
|
|
}
|
|
if turn_finished || state.dirty {
|
|
state.dirty = true;
|
|
}
|
|
}
|
|
Action::AbortTurn => {
|
|
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
|
}
|
|
Action::LessonAccept { name } => {
|
|
if let Some(ref rt) = state.session_runtime {
|
|
let _ = crate::app::review::resolve_pending_lesson(
|
|
&rt.session_dir, &state.memory_dir, &name, true,
|
|
);
|
|
}
|
|
state.push_toast(Toast::new(ToastKind::Success,
|
|
format!("accepted lesson: {}", name)));
|
|
state.dirty = true;
|
|
}
|
|
Action::LessonReject { name } => {
|
|
if let Some(ref rt) = state.session_runtime {
|
|
let _ = crate::app::review::resolve_pending_lesson(
|
|
&rt.session_dir, &state.memory_dir, &name, false,
|
|
);
|
|
}
|
|
state.push_toast(Toast::new(ToastKind::Info,
|
|
format!("rejected lesson: {}", name)));
|
|
state.dirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn a background thread that runs one full LLM turn.
|
|
///
|
|
/// Flow: check that no turn is currently in-flight → bail if so →
|
|
/// collect messages and config from state → determine API key (from
|
|
/// settings, env var, or default) → resolve generation params from
|
|
/// the current effort level → collect all tools (built-in + MCP) →
|
|
/// build `TurnCtx` → spawn a thread running `run_agent_turn` →
|
|
/// on any error, push a `TurnEvent::Error` → clear the in-flight flag
|
|
/// when the thread exits.
|
|
///
|
|
/// Why: runs on a plain OS thread so the async event loop stays responsive.
|
|
///
|
|
/// Return: nothing; results flow through `state.turn_events`.
|
|
fn spawn_turn(state: &AppStateRest) {
|
|
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
|
|
*guard
|
|
} else {
|
|
return;
|
|
};
|
|
if in_flight {
|
|
return;
|
|
}
|
|
let messages = state
|
|
.session_runtime
|
|
.as_ref()
|
|
.map(|rt| rt.messages.clone())
|
|
.unwrap_or_default();
|
|
if messages.is_empty() {
|
|
return;
|
|
}
|
|
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
|
let model = state.settings.model.clone();
|
|
let base_url = state.app_config.providers.get(&state.settings.provider)
|
|
.map(|p| p.api_base.clone());
|
|
if api_key.is_empty() {
|
|
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
|
api_key = provider_cfg.api_key_env.as_ref()
|
|
.and_then(|env| std::env::var(env).ok())
|
|
.or_else(|| provider_cfg.default_api_key.clone())
|
|
.unwrap_or_default();
|
|
}
|
|
}
|
|
if api_key.is_empty() {
|
|
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
|
}
|
|
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
|
state.misc.effort_level,
|
|
state.settings.max_tokens,
|
|
);
|
|
let mut tools = crate::tool::all_tools();
|
|
tools.extend(state.mcp_manager.as_tools());
|
|
let tool_defs = crate::tool::tool_defs(&tools);
|
|
let ctx = state.tool_ctx();
|
|
|
|
let edit_session_dir = state.session_dir.clone();
|
|
let session_id = state.session_id.clone();
|
|
let turn_events = state.turn_events.clone();
|
|
let in_flight_flag = state.turn_in_flight.clone();
|
|
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
|
|
let abort_flag = state.abort_flag.clone();
|
|
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
*in_flight_flag.lock().unwrap() = true;
|
|
|
|
let events_q = turn_events.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
let db = crate::model::msglog::open_or_create(&edit_session_dir)
|
|
.ok()
|
|
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
|
|
let tc = TurnCtx {
|
|
client: crate::service::provider::LlmClient::new(api_key, model, base_url),
|
|
tdefs: tool_defs,
|
|
tools,
|
|
ctx,
|
|
|
|
workspace_roots,
|
|
edit_log_session_dir: edit_session_dir,
|
|
session_id,
|
|
db,
|
|
temperature,
|
|
max_tokens,
|
|
abort_flag,
|
|
};
|
|
let result = run_agent_turn(tc, &messages, &events_q);
|
|
if let Err(e) = result {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::Error(e.to_string()));
|
|
}
|
|
}
|
|
if let Ok(mut flag) = in_flight_flag.lock() {
|
|
*flag = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Context bundle passed to `run_agent_turn` on its background thread.
|
|
struct TurnCtx {
|
|
client: crate::service::provider::LlmClient,
|
|
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
|
ctx: crate::tool::ToolCtx,
|
|
|
|
workspace_roots: Vec<std::path::PathBuf>,
|
|
edit_log_session_dir: std::path::PathBuf,
|
|
session_id: String,
|
|
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
|
temperature: f32,
|
|
max_tokens: u32,
|
|
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
}
|
|
|
|
/// Build an ASCII tree of the workspace directory structure for the
|
|
/// system prompt, so the LLM can see the file layout.
|
|
///
|
|
/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting
|
|
/// `.gitignore` and hidden files) → prefix `[DIR]` for directories →
|
|
/// truncate after 1000 entries.
|
|
///
|
|
/// Return: a formatted string with one entry per line.
|
|
fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("Current Workspace Directory Structure:\n");
|
|
for root in roots {
|
|
out.push_str(&format!("Root: {}\n", root.display()));
|
|
let walker = ignore::WalkBuilder::new(root)
|
|
.hidden(true)
|
|
.git_ignore(true)
|
|
.build();
|
|
let mut count = 0;
|
|
for entry in walker.flatten() {
|
|
let path = entry.path();
|
|
if let Ok(rel) = path.strip_prefix(root) {
|
|
if rel.as_os_str().is_empty() { continue; }
|
|
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
|
let prefix = if is_dir { "[DIR] " } else { " " };
|
|
out.push_str(&format!(" {}{}\n", prefix, rel.display()));
|
|
count += 1;
|
|
if count > 1000 {
|
|
out.push_str(" ... (truncated)\n");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Persist a `ChatMessage` to the SQLite message log, if a database
|
|
/// connection is available.
|
|
///
|
|
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
|
|
/// Errors are silently ignored.
|
|
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
|
if let Some(ref arc) = db {
|
|
if let Ok(conn) = arc.lock() {
|
|
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Execute one full agent turn: stream the conversation to the LLM,
|
|
/// handle tool calls, and loop until the LLM produces a non-tool response
|
|
/// or runs out of unfinished todo items.
|
|
///
|
|
/// Flow: build system prompt with workspace tree → optionally shape
|
|
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
|
|
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
|
/// and `Usage` events → on streaming success, handle tool calls (gated
|
|
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
|
/// message → check for unfinished todo.md tasks (auto-retry with a
|
|
/// system message if any remain) → finalise with `Done` and an `edits`
|
|
/// SystemNote.
|
|
///
|
|
/// On streaming failure: retry once with a non-streaming call → if that
|
|
/// also fails and there are unfinished tasks, sleep 5s and loop back;
|
|
/// otherwise return the error.
|
|
///
|
|
/// Why: non-streaming fallback handles flaky connections without aborting
|
|
/// the turn; todo.md polling lets the agent self-direct toward completeness.
|
|
///
|
|
/// Return: `Ok(())` on successful completion, or an error from the LLM
|
|
/// API after retries are exhausted.
|
|
fn run_agent_turn(
|
|
tc: TurnCtx,
|
|
messages: &[ChatMessage],
|
|
events_q: &std::sync::Mutex<VecDeque<TurnEvent>>,
|
|
) -> anyhow::Result<()> {
|
|
let mut msgs = messages.to_vec();
|
|
let mut edits_this_turn = 0u32;
|
|
let mut prev_shaped = false;
|
|
|
|
let tree_info = generate_workspace_tree(&tc.workspace_roots);
|
|
let system_text = format!(
|
|
"{}\n\n{}\n\n{}",
|
|
crate::resources::SYSTEM_PROMPT,
|
|
crate::resources::SYSTEM_TOOLS,
|
|
tree_info
|
|
);
|
|
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
|
let sys = ChatMessage::system(system_text);
|
|
archive_message(&tc.db, &tc.session_id, &sys);
|
|
msgs.insert(0, sys);
|
|
}
|
|
|
|
loop {
|
|
let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) {
|
|
let total_chars: usize = msgs.iter()
|
|
.filter_map(|m| m.content.as_deref())
|
|
.map(|c| c.len())
|
|
.sum();
|
|
let token_estimate = total_chars / 4;
|
|
prev_shaped = true;
|
|
crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate)
|
|
} else {
|
|
prev_shaped = false;
|
|
msgs.clone()
|
|
};
|
|
|
|
let mut stream_started = false;
|
|
let mut reasoning_started = false;
|
|
let mut reasoning_ended = false;
|
|
let mut usage = None;
|
|
let result = tc.client.chat_with_tools_streaming(
|
|
&wire_msgs,
|
|
Some(tc.tdefs.clone()),
|
|
Some(tc.temperature),
|
|
Some(tc.max_tokens),
|
|
|event| -> bool {
|
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
|
return false;
|
|
}
|
|
if let Ok(mut q) = events_q.lock() {
|
|
match event {
|
|
crate::app::runtime::stream::StreamEvent::Token(tok) => {
|
|
if !stream_started {
|
|
q.push_back(TurnEvent::StreamStart);
|
|
stream_started = true;
|
|
}
|
|
if reasoning_started && !reasoning_ended {
|
|
reasoning_ended = true;
|
|
q.push_back(TurnEvent::StreamToken("\n</think>\n\n".to_string()));
|
|
}
|
|
q.push_back(TurnEvent::StreamToken(tok.clone()));
|
|
}
|
|
crate::app::runtime::stream::StreamEvent::Reasoning(tok) => {
|
|
if !stream_started {
|
|
q.push_back(TurnEvent::StreamStart);
|
|
stream_started = true;
|
|
}
|
|
if !reasoning_started {
|
|
reasoning_started = true;
|
|
q.push_back(TurnEvent::StreamToken("<think>\n".to_string()));
|
|
}
|
|
q.push_back(TurnEvent::StreamToken(tok.clone()));
|
|
}
|
|
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
|
usage = Some((*prompt_tokens, *completion_tokens));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
true
|
|
},
|
|
);
|
|
|
|
if reasoning_started && !reasoning_ended {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::StreamToken("\n</think>\n\n".to_string()));
|
|
}
|
|
}
|
|
|
|
let (response, final_usage) = match result {
|
|
Ok((msg, u)) => (msg, u.or(usage)),
|
|
Err(e) => {
|
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
|
|
}
|
|
return Ok(());
|
|
}
|
|
match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) {
|
|
Ok((msg, usage_fb)) => (msg, usage_fb),
|
|
Err(api_err) => {
|
|
let todo_path = tc.ctx.session_dir.join("todo.md");
|
|
let mut has_unfinished = false;
|
|
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
|
|
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
|
|
has_unfinished = true;
|
|
}
|
|
}
|
|
if has_unfinished {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::SystemNote {
|
|
kind: "task_retry".to_string(),
|
|
message: format!("Network/API error: {}. Auto-retrying to finish tasks...", api_err),
|
|
});
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
|
continue;
|
|
}
|
|
return Err(api_err);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some((tok_in, tok_out)) = final_usage {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
|
|
}
|
|
}
|
|
|
|
let has_tool_calls = response.tool_calls.is_some()
|
|
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
|
|
|
let content = response.content.clone().unwrap_or_default();
|
|
if has_tool_calls {
|
|
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
|
archive_message(&tc.db, &tc.session_id, &response);
|
|
msgs.push(response);
|
|
for tool_call in tool_calls {
|
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
|
|
}
|
|
return Ok(());
|
|
}
|
|
let tool_name = tool_call.function.name.clone();
|
|
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
|
&tool_call.function.arguments,
|
|
);
|
|
|
|
let ws_roots: Vec<&std::path::Path> =
|
|
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
|
|
let verdict = crate::app::harness::Harness::gate_tool_call(
|
|
&tool_name,
|
|
&args,
|
|
|
|
&ws_roots,
|
|
);
|
|
|
|
let is_edit_tool = tool_name == "write" || tool_name == "edit";
|
|
let (output, is_error, is_edit) = match verdict {
|
|
Verdict::Allow => match execute_one_tool(
|
|
&tc.tools,
|
|
&tc.ctx,
|
|
&tool_name,
|
|
&tool_call.id,
|
|
&args,
|
|
&tc.edit_log_session_dir,
|
|
&tc.session_id,
|
|
&tc.db,
|
|
) {
|
|
Ok(result) => (result, false, is_edit_tool),
|
|
Err(e) => (e.to_string(), true, false),
|
|
},
|
|
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
|
};
|
|
|
|
if is_edit {
|
|
edits_this_turn += 1;
|
|
}
|
|
|
|
let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
|
|
{
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::ToolResult {
|
|
tool_call_id: tool_call.id.clone(),
|
|
tool_name: tool_name.clone(),
|
|
output: output.clone(),
|
|
is_error,
|
|
path: tool_path,
|
|
});
|
|
}
|
|
}
|
|
|
|
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
|
archive_message(&tc.db, &tc.session_id, &tool_msg);
|
|
msgs.push(tool_msg);
|
|
}
|
|
} else {
|
|
if !content.is_empty() {
|
|
archive_message(&tc.db, &tc.session_id, &response);
|
|
if let Ok(mut q) = events_q.lock() {
|
|
if stream_started {
|
|
q.push_back(TurnEvent::StreamDone(response.clone()));
|
|
} else {
|
|
q.push_back(TurnEvent::AssistantMessage(response.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
let todo_path = tc.ctx.session_dir.join("todo.md");
|
|
let mut has_unfinished = false;
|
|
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
|
|
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
|
|
has_unfinished = true;
|
|
}
|
|
}
|
|
|
|
if has_unfinished {
|
|
let sys_text = "You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished.";
|
|
let msg = ChatMessage::system(sys_text);
|
|
archive_message(&tc.db, &tc.session_id, &msg);
|
|
msgs.push(msg);
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::SystemNote {
|
|
kind: "task_retry".to_string(),
|
|
message: sys_text.to_string(),
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if edits_this_turn > 0 {
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::SystemNote {
|
|
kind: "edits".to_string(),
|
|
message: edits_this_turn.to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
q.push_back(TurnEvent::Done);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Execute a single tool call: find the tool by name, snapshot the file
|
|
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
|
|
/// write/edit, and return the output.
|
|
///
|
|
/// Flow: iterate tools → match by name → for write/edit, snapshot the
|
|
/// pre-existing file content into the blob store → call `tool.run()` →
|
|
/// for write/edit, compute SHA-256 of the new content and append an
|
|
/// `EditLogEntry` → return the tool output string.
|
|
///
|
|
/// Why: snapshots enable the rewind feature to restore previous content
|
|
/// after a write/edit.
|
|
///
|
|
/// Return: the tool's stdout string, or an error if no matching tool was
|
|
/// found or the tool run itself failed.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn execute_one_tool(
|
|
tools: &[Box<dyn crate::tool::Tool>],
|
|
ctx: &crate::tool::ToolCtx,
|
|
name: &str,
|
|
tool_call_id: &str,
|
|
args: &serde_json::Value,
|
|
session_dir: &std::path::Path,
|
|
session_id: &str,
|
|
db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
|
) -> anyhow::Result<String> {
|
|
for tool in tools {
|
|
if tool.name() == name {
|
|
// Snapshot current file content before write/edit for rewind
|
|
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
|
|
if let Some(ref arc) = db {
|
|
if let Ok(conn) = arc.lock() {
|
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
|
|
if let Ok(bytes) = std::fs::read(&abs_path) {
|
|
let _ = crate::model::msglog::store_blob(
|
|
&conn, session_id, tool_call_id, &bytes, None,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let result = tool.run(ctx, args)?;
|
|
if name == "write" || name == "edit" {
|
|
let reason = args
|
|
.get("reason")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unnamed");
|
|
let path = args
|
|
.get("path")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown");
|
|
let content_sha256 = {
|
|
let content = args.get("content").or_else(|| args.get("new"));
|
|
let hash = sha2::Sha256::digest(
|
|
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
|
);
|
|
format!("{:x}", hash)
|
|
};
|
|
let bytes_delta = if name == "write" {
|
|
args.get("content")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.len() as i64)
|
|
.unwrap_or(0)
|
|
} else {
|
|
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
|
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
|
(new.len() as i64 - old.len() as i64).abs()
|
|
};
|
|
let entry = crate::model::editlog::EditLogEntry {
|
|
ts: chrono::Utc::now().timestamp_millis(),
|
|
tool: name.to_string(),
|
|
path: path.to_string(),
|
|
reason: reason.to_string(),
|
|
content_sha256,
|
|
bytes_delta,
|
|
origin: ctx.origin.tag(),
|
|
session_id: session_id.to_string(),
|
|
};
|
|
let mut el = crate::model::editlog::EditLog::new(session_dir);
|
|
el.append(entry).ok();
|
|
}
|
|
return Ok(result);
|
|
}
|
|
}
|
|
anyhow::bail!("tool not found: {}", name)
|
|
}
|
|
|
|
/// Optionally push a review-available toast at the end of a turn that
|
|
/// performed edits.
|
|
///
|
|
/// Flow: skip if review is disabled → skip if `edit_count` is zero →
|
|
/// push an info toast listing the number of modified files.
|
|
///
|
|
/// Why: does not launch the review itself (that happens inside
|
|
/// `should_trigger_review` on `Tick`), only informs the user that
|
|
/// a review has material to examine.
|
|
fn maybe_trigger_review(state: &mut AppStateRest) {
|
|
if !state.settings.review_enabled {
|
|
return;
|
|
}
|
|
let edit_count = state
|
|
.session_runtime
|
|
.as_ref()
|
|
.map(|rt| rt.edit_count)
|
|
.unwrap_or(0);
|
|
if edit_count == 0 {
|
|
return;
|
|
}
|
|
state.push_toast(Toast::new(
|
|
ToastKind::Info,
|
|
format!("{} file(s) modified this turn. Review available.", edit_count),
|
|
));
|
|
}
|
|
|
|
/// Persist the current session metadata and conversation to disk.
|
|
///
|
|
/// Flow: build a `Session` object → save its metadata → write
|
|
/// `rt.messages` as JSON to the conversation file → errors are silently
|
|
/// ignored.
|
|
///
|
|
/// Why: called on `ForceQuit` so the session can be resumed later.
|
|
fn save_current_session(state: &AppStateRest) {
|
|
let base = state.store_base_dir();
|
|
let session = crate::model::session::Session::new(
|
|
state.session_id.clone(),
|
|
"session".to_string(),
|
|
);
|
|
let _ = session.save(&base);
|
|
if let Some(ref rt) = state.session_runtime {
|
|
let conv_path = session.conversation_path(&base);
|
|
if let Ok(data) = serde_json::to_string(&rt.messages) {
|
|
let _ = std::fs::write(&conv_path, data);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run a browser-based OAuth PKCE flow for the given provider.
|
|
///
|
|
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
|
|
/// or a custom provider via env vars) → bind a loopback server → generate
|
|
/// a PKCE code verifier and challenge → build the authorisation URL →
|
|
/// wait for the redirect code on the loopback server (with a 120s timeout)
|
|
/// → exchange the code for a token → save the token to
|
|
/// `~/.config/zesdex/oauth_{provider}.json`.
|
|
///
|
|
/// Why: the `webbrowser::open` call is currently commented out; the user
|
|
/// must open the auth URL manually until that line is reinstated.
|
|
///
|
|
/// Return: a success message on completion, or an error if the flow fails
|
|
/// at any step.
|
|
fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
|
use crate::service::oauth::manager::{OAuthConfig, OAuthManager};
|
|
use crate::service::oauth::loopback::LoopbackServer;
|
|
use crate::service::oauth::pkce::CodeVerifier;
|
|
|
|
let config = match provider {
|
|
"zen" | "opencode" => OAuthConfig {
|
|
auth_url: "https://opencode.ai/zen/oauth/authorize".to_string(),
|
|
token_url: "https://opencode.ai/zen/oauth/token".to_string(),
|
|
client_id: std::env::var("ZEN_CLIENT_ID")
|
|
.unwrap_or_else(|_| "zesdex".to_string()),
|
|
client_secret: std::env::var("ZEN_CLIENT_SECRET").ok(),
|
|
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
|
},
|
|
"openai" => OAuthConfig {
|
|
auth_url: "https://auth0.openai.com/authorize".to_string(),
|
|
token_url: "https://auth0.openai.com/oauth/token".to_string(),
|
|
client_id: std::env::var("OPENAI_CLIENT_ID")
|
|
.unwrap_or_else(|_| "zesdex".to_string()),
|
|
client_secret: std::env::var("OPENAI_CLIENT_SECRET").ok(),
|
|
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
|
},
|
|
other => {
|
|
let auth_url = std::env::var(format!("{}_AUTH_URL", other.to_uppercase()))
|
|
.map_err(|_| anyhow::anyhow!("unknown provider '{}'. Set {}_AUTH_URL env var.", other, other.to_uppercase()))?;
|
|
let token_url = std::env::var(format!("{}_TOKEN_URL", other.to_uppercase()))
|
|
.map_err(|_| anyhow::anyhow!("{}_TOKEN_URL not set", other.to_uppercase()))?;
|
|
let client_id = std::env::var(format!("{}_CLIENT_ID", other.to_uppercase()))
|
|
.unwrap_or_else(|_| "zesdex".to_string());
|
|
OAuthConfig {
|
|
auth_url,
|
|
token_url,
|
|
client_id,
|
|
client_secret: std::env::var(format!("{}_CLIENT_SECRET", other.to_uppercase())).ok(),
|
|
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
|
}
|
|
}
|
|
};
|
|
|
|
let server = LoopbackServer::bind()?;
|
|
let redirect_uri = server.redirect_uri();
|
|
|
|
let verifier = CodeVerifier::new();
|
|
let challenge = verifier.challenge();
|
|
let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16)));
|
|
|
|
let mut manager = OAuthManager::new(config.clone());
|
|
let _auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str());
|
|
|
|
// let _ = webbrowser::open(&auth_url);
|
|
|
|
let code = server.wait_for_code(120_000)?;
|
|
|
|
manager.exchange_code(&code, &redirect_uri, verifier.as_str())
|
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
|
|
|
if let Some(ref token) = manager.token {
|
|
let token_path = dirs::config_dir()
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
.join("zesdex")
|
|
.join(format!("oauth_{}.json", provider));
|
|
if let Some(parent) = token_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let _ = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default());
|
|
}
|
|
|
|
Ok(format!("Successfully authenticated with {}.", provider))
|
|
}
|
|
|
|
/// Generate `n` pseudo-random bytes from the current sub-second timestamp.
|
|
///
|
|
/// Why: avoids pulling in a full RNG crate for the OAuth state token;
|
|
/// sufficient for a nonce that only needs to be unpredictable over the
|
|
/// lifetime of a single OAuth flow.
|
|
fn rand_bytes(n: usize) -> Vec<u8> {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos();
|
|
(0..n).map(|i| ((seed >> (i % 4 * 8)) ^ (i as u32 * 2654435761)) as u8).collect()
|
|
}
|
|
|
|
|