feat: Complete LLMX v0.1.0 - Rebrand from Codex with LiteLLM Integration
This release represents a comprehensive transformation of the codebase from Codex to LLMX, enhanced with LiteLLM integration to support 100+ LLM providers through a unified API. ## Major Changes ### Phase 1: Repository & Infrastructure Setup - Established new repository structure and branching strategy - Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md) - Set up development environment and tooling configuration ### Phase 2: Rust Workspace Transformation - Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates) - Updated package names, binary names, and workspace members - Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs - Updated all internal references, imports, and type names - Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/ - Fixed all Rust compilation errors after mass rename ### Phase 3: LiteLLM Integration - Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.) - Implemented OpenAI-compatible Chat Completions API support - Added model family detection and provider-specific handling - Updated authentication to support LiteLLM API keys - Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL - Added LLMX_API_KEY for unified authentication - Enhanced error handling for Chat Completions API responses - Implemented fallback mechanisms between Responses API and Chat Completions API ### Phase 4: TypeScript/Node.js Components - Renamed npm package: @codex/codex-cli → @valknar/llmx - Updated TypeScript SDK to use new LLMX APIs and endpoints - Fixed all TypeScript compilation and linting errors - Updated SDK tests to support both API backends - Enhanced mock server to handle multiple API formats - Updated build scripts for cross-platform packaging ### Phase 5: Configuration & Documentation - Updated all configuration files to use LLMX naming - Rewrote README and documentation for LLMX branding - Updated config paths: ~/.codex/ → ~/.llmx/ - Added comprehensive LiteLLM setup guide - Updated all user-facing strings and help text - Created release plan and migration documentation ### Phase 6: Testing & Validation - Fixed all Rust tests for new naming scheme - Updated snapshot tests in TUI (36 frame files) - Fixed authentication storage tests - Updated Chat Completions payload and SSE tests - Fixed SDK tests for new API endpoints - Ensured compatibility with Claude Sonnet 4.5 model - Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL) ### Phase 7: Build & Release Pipeline - Updated GitHub Actions workflows for LLMX binary names - Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/ - Updated CI/CD pipelines for new package names - Made Apple code signing optional in release workflow - Enhanced npm packaging resilience for partial platform builds - Added Windows sandbox support to workspace - Updated dotslash configuration for new binary names ### Phase 8: Final Polish - Renamed all assets (.github images, labels, templates) - Updated VSCode and DevContainer configurations - Fixed all clippy warnings and formatting issues - Applied cargo fmt and prettier formatting across codebase - Updated issue templates and pull request templates - Fixed all remaining UI text references ## Technical Details **Breaking Changes:** - Binary name changed from `codex` to `llmx` - Config directory changed from `~/.codex/` to `~/.llmx/` - Environment variables renamed (CODEX_* → LLMX_*) - npm package renamed to `@valknar/llmx` **New Features:** - Support for 100+ LLM providers via LiteLLM - Unified authentication with LLMX_API_KEY - Enhanced model provider detection and handling - Improved error handling and fallback mechanisms **Files Changed:** - 578 files modified across Rust, TypeScript, and documentation - 30+ Rust crates renamed and updated - Complete rebrand of UI, CLI, and documentation - All tests updated and passing **Dependencies:** - Updated Cargo.lock with new package names - Updated npm dependencies in llmx-cli - Enhanced OpenAPI models for LLMX backend This release establishes LLMX as a standalone project with comprehensive LiteLLM integration, maintaining full backward compatibility with existing functionality while opening support for a wide ecosystem of LLM providers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Sebastian Krüger <support@pivoine.art>
This commit is contained in:
109
llmx-rs/exec/src/cli.rs
Normal file
109
llmx-rs/exec/src/cli.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use clap::Parser;
|
||||
use clap::ValueEnum;
|
||||
use llmx_common::CliConfigOverrides;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Action to perform. If omitted, runs a new non-interactive session.
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[arg(long = "oss", default_value_t = false)]
|
||||
pub oss: bool,
|
||||
|
||||
/// Select the sandbox policy to use when executing model-generated shell
|
||||
/// commands.
|
||||
#[arg(long = "sandbox", short = 's', value_enum)]
|
||||
pub sandbox_mode: Option<llmx_common::SandboxModeCliArg>,
|
||||
|
||||
/// Configuration profile from config.toml to specify default options.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<String>,
|
||||
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write).
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
pub full_auto: bool,
|
||||
|
||||
/// Skip all confirmation prompts and execute commands without sandboxing.
|
||||
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
|
||||
#[arg(
|
||||
long = "dangerously-bypass-approvals-and-sandbox",
|
||||
alias = "yolo",
|
||||
default_value_t = false,
|
||||
conflicts_with = "full_auto"
|
||||
)]
|
||||
pub dangerously_bypass_approvals_and_sandbox: bool,
|
||||
|
||||
/// Tell the agent to use the specified directory as its working root.
|
||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Allow running Llmx outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Path to a JSON Schema file describing the model's final response shape.
|
||||
#[arg(long = "output-schema", value_name = "FILE")]
|
||||
pub output_schema: Option<PathBuf>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Specifies color settings for use in the output.
|
||||
#[arg(long = "color", value_enum, default_value_t = Color::Auto)]
|
||||
pub color: Color,
|
||||
|
||||
/// Print events to stdout as JSONL.
|
||||
#[arg(long = "json", alias = "experimental-json", default_value_t = false)]
|
||||
pub json: bool,
|
||||
|
||||
/// Specifies file where the last message from the agent should be written.
|
||||
#[arg(long = "output-last-message", short = 'o', value_name = "FILE")]
|
||||
pub last_message_file: Option<PathBuf>,
|
||||
|
||||
/// Initial instructions for the agent. If not provided as an argument (or
|
||||
/// if `-` is used), instructions are read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub enum Command {
|
||||
/// Resume a previous session by id or pick the most recent with --last.
|
||||
Resume(ResumeArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ResumeArgs {
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
pub session_id: Option<String>,
|
||||
|
||||
/// Resume the most recent recorded session (newest) without specifying an id.
|
||||
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
|
||||
pub last: bool,
|
||||
|
||||
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum Color {
|
||||
Always,
|
||||
Never,
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
45
llmx-rs/exec/src/event_processor.rs
Normal file
45
llmx-rs/exec/src/event_processor.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::path::Path;
|
||||
|
||||
use llmx_core::config::Config;
|
||||
use llmx_core::protocol::Event;
|
||||
use llmx_core::protocol::SessionConfiguredEvent;
|
||||
|
||||
pub(crate) enum LlmxStatus {
|
||||
Running,
|
||||
InitiateShutdown,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub(crate) trait EventProcessor {
|
||||
/// Print summary of effective configuration and user prompt.
|
||||
fn print_config_summary(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
prompt: &str,
|
||||
session_configured: &SessionConfiguredEvent,
|
||||
);
|
||||
|
||||
/// Handle a single event emitted by the agent.
|
||||
fn process_event(&mut self, event: Event) -> LlmxStatus;
|
||||
|
||||
fn print_final_output(&mut self) {}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file: &Path) {
|
||||
let message = last_agent_message.unwrap_or_default();
|
||||
write_last_message_file(message, Some(output_file));
|
||||
if last_agent_message.is_none() {
|
||||
eprintln!(
|
||||
"Warning: no last agent message; wrote empty content to {}",
|
||||
output_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
|
||||
if let Some(path) = last_message_path
|
||||
&& let Err(e) = std::fs::write(path, contents)
|
||||
{
|
||||
eprintln!("Failed to write last message file {path:?}: {e}");
|
||||
}
|
||||
}
|
||||
595
llmx-rs/exec/src/event_processor_with_human_output.rs
Normal file
595
llmx-rs/exec/src/event_processor_with_human_output.rs
Normal file
@@ -0,0 +1,595 @@
|
||||
use llmx_common::elapsed::format_duration;
|
||||
use llmx_common::elapsed::format_elapsed;
|
||||
use llmx_core::config::Config;
|
||||
use llmx_core::protocol::AgentMessageEvent;
|
||||
use llmx_core::protocol::AgentReasoningRawContentEvent;
|
||||
use llmx_core::protocol::BackgroundEventEvent;
|
||||
use llmx_core::protocol::DeprecationNoticeEvent;
|
||||
use llmx_core::protocol::ErrorEvent;
|
||||
use llmx_core::protocol::Event;
|
||||
use llmx_core::protocol::EventMsg;
|
||||
use llmx_core::protocol::ExecCommandBeginEvent;
|
||||
use llmx_core::protocol::ExecCommandEndEvent;
|
||||
use llmx_core::protocol::FileChange;
|
||||
use llmx_core::protocol::McpInvocation;
|
||||
use llmx_core::protocol::McpToolCallBeginEvent;
|
||||
use llmx_core::protocol::McpToolCallEndEvent;
|
||||
use llmx_core::protocol::PatchApplyBeginEvent;
|
||||
use llmx_core::protocol::PatchApplyEndEvent;
|
||||
use llmx_core::protocol::SessionConfiguredEvent;
|
||||
use llmx_core::protocol::StreamErrorEvent;
|
||||
use llmx_core::protocol::TaskCompleteEvent;
|
||||
use llmx_core::protocol::TurnAbortReason;
|
||||
use llmx_core::protocol::TurnDiffEvent;
|
||||
use llmx_core::protocol::WarningEvent;
|
||||
use llmx_core::protocol::WebSearchEndEvent;
|
||||
use llmx_protocol::num_format::format_with_separators;
|
||||
use owo_colors::OwoColorize;
|
||||
use owo_colors::Style;
|
||||
use shlex::try_join;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::LlmxStatus;
|
||||
use crate::event_processor::handle_last_message;
|
||||
use llmx_common::create_config_summary_entries;
|
||||
use llmx_protocol::plan_tool::StepStatus;
|
||||
use llmx_protocol::plan_tool::UpdatePlanArgs;
|
||||
|
||||
/// This should be configurable. When used in CI, users may not want to impose
|
||||
/// a limit so they can see the full transcript.
|
||||
const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
|
||||
pub(crate) struct EventProcessorWithHumanOutput {
|
||||
call_id_to_patch: HashMap<String, PatchApplyBegin>,
|
||||
|
||||
// To ensure that --color=never is respected, ANSI escapes _must_ be added
|
||||
// using .style() with one of these fields. If you need a new style, add a
|
||||
// new field here.
|
||||
bold: Style,
|
||||
italic: Style,
|
||||
dimmed: Style,
|
||||
|
||||
magenta: Style,
|
||||
red: Style,
|
||||
green: Style,
|
||||
cyan: Style,
|
||||
yellow: Style,
|
||||
|
||||
/// Whether to include `AgentReasoning` events in the output.
|
||||
show_agent_reasoning: bool,
|
||||
show_raw_agent_reasoning: bool,
|
||||
last_message_path: Option<PathBuf>,
|
||||
last_total_token_usage: Option<llmx_core::protocol::TokenUsageInfo>,
|
||||
final_message: Option<String>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithHumanOutput {
|
||||
pub(crate) fn create_with_ansi(
|
||||
with_ansi: bool,
|
||||
config: &Config,
|
||||
last_message_path: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let call_id_to_patch = HashMap::new();
|
||||
|
||||
if with_ansi {
|
||||
Self {
|
||||
call_id_to_patch,
|
||||
bold: Style::new().bold(),
|
||||
italic: Style::new().italic(),
|
||||
dimmed: Style::new().dimmed(),
|
||||
magenta: Style::new().magenta(),
|
||||
red: Style::new().red(),
|
||||
green: Style::new().green(),
|
||||
cyan: Style::new().cyan(),
|
||||
yellow: Style::new().yellow(),
|
||||
show_agent_reasoning: !config.hide_agent_reasoning,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
last_message_path,
|
||||
last_total_token_usage: None,
|
||||
final_message: None,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
call_id_to_patch,
|
||||
bold: Style::new(),
|
||||
italic: Style::new(),
|
||||
dimmed: Style::new(),
|
||||
magenta: Style::new(),
|
||||
red: Style::new(),
|
||||
green: Style::new(),
|
||||
cyan: Style::new(),
|
||||
yellow: Style::new(),
|
||||
show_agent_reasoning: !config.hide_agent_reasoning,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
last_message_path,
|
||||
last_total_token_usage: None,
|
||||
final_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PatchApplyBegin {
|
||||
start_time: Instant,
|
||||
auto_approved: bool,
|
||||
}
|
||||
|
||||
/// Timestamped helper. The timestamp is styled with self.dimmed.
|
||||
macro_rules! ts_msg {
|
||||
($self:ident, $($arg:tt)*) => {{
|
||||
eprintln!($($arg)*);
|
||||
}};
|
||||
}
|
||||
|
||||
impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
/// Print a concise summary of the effective configuration that will be used
|
||||
/// for the session. This mirrors the information shown in the TUI welcome
|
||||
/// screen.
|
||||
fn print_config_summary(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
prompt: &str,
|
||||
session_configured_event: &SessionConfiguredEvent,
|
||||
) {
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
ts_msg!(self, "LLMX v{} (research preview)\n--------", VERSION);
|
||||
|
||||
let mut entries = create_config_summary_entries(config);
|
||||
entries.push((
|
||||
"session id",
|
||||
session_configured_event.session_id.to_string(),
|
||||
));
|
||||
|
||||
for (key, value) in entries {
|
||||
eprintln!("{} {}", format!("{key}:").style(self.bold), value);
|
||||
}
|
||||
|
||||
eprintln!("--------");
|
||||
|
||||
// Echo the prompt that will be sent to the agent so it is visible in the
|
||||
// transcript/logs before any events come in. Note the prompt may have been
|
||||
// read from stdin, so it may not be visible in the terminal otherwise.
|
||||
ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt);
|
||||
}
|
||||
|
||||
fn process_event(&mut self, event: Event) -> LlmxStatus {
|
||||
let Event { id: _, msg } = event;
|
||||
match msg {
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
let prefix = "ERROR:".style(self.red);
|
||||
ts_msg!(self, "{prefix} {message}");
|
||||
}
|
||||
EventMsg::Warning(WarningEvent { message }) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {message}",
|
||||
"warning:".style(self.yellow).style(self.bold)
|
||||
);
|
||||
}
|
||||
EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {summary}",
|
||||
"deprecated:".style(self.magenta).style(self.bold)
|
||||
);
|
||||
if let Some(details) = details {
|
||||
ts_msg!(self, " {}", details.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
ts_msg!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => {
|
||||
ts_msg!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::TaskStarted(_) => {
|
||||
// Ignore.
|
||||
}
|
||||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||||
let last_message = last_agent_message.as_deref();
|
||||
if let Some(output_file) = self.last_message_path.as_deref() {
|
||||
handle_last_message(last_message, output_file);
|
||||
}
|
||||
|
||||
self.final_message = last_agent_message;
|
||||
|
||||
return LlmxStatus::InitiateShutdown;
|
||||
}
|
||||
EventMsg::TokenCount(ev) => {
|
||||
self.last_total_token_usage = ev.info;
|
||||
}
|
||||
|
||||
EventMsg::AgentReasoningSectionBreak(_) => {
|
||||
if !self.show_agent_reasoning {
|
||||
return LlmxStatus::Running;
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
if self.show_raw_agent_reasoning {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"thinking".style(self.italic).style(self.magenta),
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"llmx".style(self.italic).style(self.magenta),
|
||||
message,
|
||||
);
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
|
||||
eprint!(
|
||||
"{}\n{} in {}",
|
||||
"exec".style(self.italic).style(self.magenta),
|
||||
escape_command(&command).style(self.bold),
|
||||
cwd.to_string_lossy(),
|
||||
);
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
aggregated_output,
|
||||
duration,
|
||||
exit_code,
|
||||
..
|
||||
}) => {
|
||||
let duration = format!(" in {}", format_duration(duration));
|
||||
|
||||
let truncated_output = aggregated_output
|
||||
.lines()
|
||||
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
match exit_code {
|
||||
0 => {
|
||||
let title = format!(" succeeded{duration}:");
|
||||
ts_msg!(self, "{}", title.style(self.green));
|
||||
}
|
||||
_ => {
|
||||
let title = format!(" exited {exit_code}{duration}:");
|
||||
ts_msg!(self, "{}", title.style(self.red));
|
||||
}
|
||||
}
|
||||
eprintln!("{}", truncated_output.style(self.dimmed));
|
||||
}
|
||||
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id: _,
|
||||
invocation,
|
||||
}) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"tool".style(self.magenta),
|
||||
format_mcp_invocation(&invocation).style(self.bold),
|
||||
);
|
||||
}
|
||||
EventMsg::McpToolCallEnd(tool_call_end_event) => {
|
||||
let is_success = tool_call_end_event.is_success();
|
||||
let McpToolCallEndEvent {
|
||||
call_id: _,
|
||||
result,
|
||||
invocation,
|
||||
duration,
|
||||
} = tool_call_end_event;
|
||||
|
||||
let duration = format!(" in {}", format_duration(duration));
|
||||
|
||||
let status_str = if is_success { "success" } else { "failed" };
|
||||
let title_style = if is_success { self.green } else { self.red };
|
||||
let title = format!(
|
||||
"{} {status_str}{duration}:",
|
||||
format_mcp_invocation(&invocation)
|
||||
);
|
||||
|
||||
ts_msg!(self, "{}", title.style(title_style));
|
||||
|
||||
if let Ok(res) = result {
|
||||
let val: serde_json::Value = res.into();
|
||||
let pretty =
|
||||
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
|
||||
|
||||
for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
|
||||
eprintln!("{}", line.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
|
||||
ts_msg!(self, "🌐 Searched: {query}");
|
||||
}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id,
|
||||
auto_approved,
|
||||
changes,
|
||||
}) => {
|
||||
// Store metadata so we can calculate duration later when we
|
||||
// receive the corresponding PatchApplyEnd event.
|
||||
self.call_id_to_patch.insert(
|
||||
call_id,
|
||||
PatchApplyBegin {
|
||||
start_time: Instant::now(),
|
||||
auto_approved,
|
||||
},
|
||||
);
|
||||
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}",
|
||||
"file update".style(self.magenta).style(self.italic),
|
||||
);
|
||||
|
||||
// Pretty-print the patch summary with colored diff markers so
|
||||
// it's easy to scan in the terminal output.
|
||||
for (path, change) in changes.iter() {
|
||||
match change {
|
||||
FileChange::Add { content } => {
|
||||
let header = format!(
|
||||
"{} {}",
|
||||
format_file_change(change),
|
||||
path.to_string_lossy()
|
||||
);
|
||||
eprintln!("{}", header.style(self.magenta));
|
||||
for line in content.lines() {
|
||||
eprintln!("{}", line.style(self.green));
|
||||
}
|
||||
}
|
||||
FileChange::Delete { content } => {
|
||||
let header = format!(
|
||||
"{} {}",
|
||||
format_file_change(change),
|
||||
path.to_string_lossy()
|
||||
);
|
||||
eprintln!("{}", header.style(self.magenta));
|
||||
for line in content.lines() {
|
||||
eprintln!("{}", line.style(self.red));
|
||||
}
|
||||
}
|
||||
FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
} => {
|
||||
let header = if let Some(dest) = move_path {
|
||||
format!(
|
||||
"{} {} -> {}",
|
||||
format_file_change(change),
|
||||
path.to_string_lossy(),
|
||||
dest.to_string_lossy()
|
||||
)
|
||||
} else {
|
||||
format!("{} {}", format_file_change(change), path.to_string_lossy())
|
||||
};
|
||||
eprintln!("{}", header.style(self.magenta));
|
||||
|
||||
// Colorize diff lines. We keep file header lines
|
||||
// (--- / +++) without extra coloring so they are
|
||||
// still readable.
|
||||
for diff_line in unified_diff.lines() {
|
||||
if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
|
||||
eprintln!("{}", diff_line.style(self.green));
|
||||
} else if diff_line.starts_with('-')
|
||||
&& !diff_line.starts_with("---")
|
||||
{
|
||||
eprintln!("{}", diff_line.style(self.red));
|
||||
} else {
|
||||
eprintln!("{diff_line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
call_id,
|
||||
stdout,
|
||||
stderr,
|
||||
success,
|
||||
..
|
||||
}) => {
|
||||
let patch_begin = self.call_id_to_patch.remove(&call_id);
|
||||
|
||||
// Compute duration and summary label similar to exec commands.
|
||||
let (duration, label) = if let Some(PatchApplyBegin {
|
||||
start_time,
|
||||
auto_approved,
|
||||
}) = patch_begin
|
||||
{
|
||||
(
|
||||
format!(" in {}", format_elapsed(start_time)),
|
||||
format!("apply_patch(auto_approved={auto_approved})"),
|
||||
)
|
||||
} else {
|
||||
(String::new(), format!("apply_patch('{call_id}')"))
|
||||
};
|
||||
|
||||
let (exit_code, output, title_style) = if success {
|
||||
(0, stdout, self.green)
|
||||
} else {
|
||||
(1, stderr, self.red)
|
||||
};
|
||||
|
||||
let title = format!("{label} exited {exit_code}{duration}:");
|
||||
ts_msg!(self, "{}", title.style(title_style));
|
||||
for line in output.lines() {
|
||||
eprintln!("{}", line.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}",
|
||||
"file update:".style(self.magenta).style(self.italic)
|
||||
);
|
||||
eprintln!("{unified_diff}");
|
||||
}
|
||||
EventMsg::AgentReasoning(agent_reasoning_event) => {
|
||||
if self.show_agent_reasoning {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"thinking".style(self.italic).style(self.magenta),
|
||||
agent_reasoning_event.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
EventMsg::SessionConfigured(session_configured_event) => {
|
||||
let SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model,
|
||||
reasoning_effort: _,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
initial_messages: _,
|
||||
rollout_path: _,
|
||||
} = session_configured_event;
|
||||
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"llmx session".style(self.magenta).style(self.bold),
|
||||
conversation_id.to_string().style(self.dimmed)
|
||||
);
|
||||
|
||||
ts_msg!(self, "model: {}", model);
|
||||
eprintln!();
|
||||
}
|
||||
EventMsg::PlanUpdate(plan_update_event) => {
|
||||
let UpdatePlanArgs { explanation, plan } = plan_update_event;
|
||||
|
||||
// Header
|
||||
ts_msg!(self, "{}", "Plan update".style(self.magenta));
|
||||
|
||||
// Optional explanation
|
||||
if let Some(explanation) = explanation
|
||||
&& !explanation.trim().is_empty()
|
||||
{
|
||||
ts_msg!(self, "{}", explanation.style(self.italic));
|
||||
}
|
||||
|
||||
// Pretty-print the plan items with simple status markers.
|
||||
for item in plan {
|
||||
match item.status {
|
||||
StepStatus::Completed => {
|
||||
ts_msg!(self, " {} {}", "✓".style(self.green), item.step);
|
||||
}
|
||||
StepStatus::InProgress => {
|
||||
ts_msg!(self, " {} {}", "→".style(self.cyan), item.step);
|
||||
}
|
||||
StepStatus::Pending => {
|
||||
ts_msg!(
|
||||
self,
|
||||
" {} {}",
|
||||
"•".style(self.dimmed),
|
||||
item.step.style(self.dimmed)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::ViewImageToolCall(view) => {
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"viewed image".style(self.magenta),
|
||||
view.path.display()
|
||||
);
|
||||
}
|
||||
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
ts_msg!(self, "task interrupted");
|
||||
}
|
||||
TurnAbortReason::Replaced => {
|
||||
ts_msg!(self, "task aborted: replaced by a new task");
|
||||
}
|
||||
TurnAbortReason::ReviewEnded => {
|
||||
ts_msg!(self, "task aborted: review ended");
|
||||
}
|
||||
},
|
||||
EventMsg::ShutdownComplete => return LlmxStatus::Shutdown,
|
||||
EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::UserMessage(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::UndoStarted(_) => {}
|
||||
}
|
||||
LlmxStatus::Running
|
||||
}
|
||||
|
||||
fn print_final_output(&mut self) {
|
||||
if let Some(usage_info) = &self.last_total_token_usage {
|
||||
eprintln!(
|
||||
"{}\n{}",
|
||||
"tokens used".style(self.magenta).style(self.italic),
|
||||
format_with_separators(usage_info.total_token_usage.blended_total())
|
||||
);
|
||||
}
|
||||
|
||||
// If the user has not piped the final message to a file, they will see
|
||||
// it twice: once written to stderr as part of the normal event
|
||||
// processing, and once here on stdout. We print the token summary above
|
||||
// to help break up the output visually in that case.
|
||||
#[allow(clippy::print_stdout)]
|
||||
if let Some(message) = &self.final_message {
|
||||
if message.ends_with('\n') {
|
||||
print!("{message}");
|
||||
} else {
|
||||
println!("{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_command(command: &[String]) -> String {
|
||||
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "))
|
||||
}
|
||||
|
||||
fn format_file_change(change: &FileChange) -> &'static str {
|
||||
match change {
|
||||
FileChange::Add { .. } => "A",
|
||||
FileChange::Delete { .. } => "D",
|
||||
FileChange::Update {
|
||||
move_path: Some(_), ..
|
||||
} => "R",
|
||||
FileChange::Update {
|
||||
move_path: None, ..
|
||||
} => "M",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_invocation(invocation: &McpInvocation) -> String {
|
||||
// Build fully-qualified tool name: server.tool
|
||||
let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool);
|
||||
|
||||
// Format arguments as compact JSON so they fit on one line.
|
||||
let args_str = invocation
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if args_str.is_empty() {
|
||||
format!("{fq_tool_name}()")
|
||||
} else {
|
||||
format!("{fq_tool_name}({args_str})")
|
||||
}
|
||||
}
|
||||
501
llmx-rs/exec/src/event_processor_with_jsonl_output.rs
Normal file
501
llmx-rs/exec/src/event_processor_with_jsonl_output.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::LlmxStatus;
|
||||
use crate::event_processor::handle_last_message;
|
||||
use crate::exec_events::AgentMessageItem;
|
||||
use crate::exec_events::CommandExecutionItem;
|
||||
use crate::exec_events::CommandExecutionStatus;
|
||||
use crate::exec_events::ErrorItem;
|
||||
use crate::exec_events::FileChangeItem;
|
||||
use crate::exec_events::FileUpdateChange;
|
||||
use crate::exec_events::ItemCompletedEvent;
|
||||
use crate::exec_events::ItemStartedEvent;
|
||||
use crate::exec_events::ItemUpdatedEvent;
|
||||
use crate::exec_events::McpToolCallItem;
|
||||
use crate::exec_events::McpToolCallItemError;
|
||||
use crate::exec_events::McpToolCallItemResult;
|
||||
use crate::exec_events::McpToolCallStatus;
|
||||
use crate::exec_events::PatchApplyStatus;
|
||||
use crate::exec_events::PatchChangeKind;
|
||||
use crate::exec_events::ReasoningItem;
|
||||
use crate::exec_events::ThreadErrorEvent;
|
||||
use crate::exec_events::ThreadEvent;
|
||||
use crate::exec_events::ThreadItem;
|
||||
use crate::exec_events::ThreadItemDetails;
|
||||
use crate::exec_events::ThreadStartedEvent;
|
||||
use crate::exec_events::TodoItem;
|
||||
use crate::exec_events::TodoListItem;
|
||||
use crate::exec_events::TurnCompletedEvent;
|
||||
use crate::exec_events::TurnFailedEvent;
|
||||
use crate::exec_events::TurnStartedEvent;
|
||||
use crate::exec_events::Usage;
|
||||
use crate::exec_events::WebSearchItem;
|
||||
use llmx_core::config::Config;
|
||||
use llmx_core::protocol::AgentMessageEvent;
|
||||
use llmx_core::protocol::AgentReasoningEvent;
|
||||
use llmx_core::protocol::Event;
|
||||
use llmx_core::protocol::EventMsg;
|
||||
use llmx_core::protocol::ExecCommandBeginEvent;
|
||||
use llmx_core::protocol::ExecCommandEndEvent;
|
||||
use llmx_core::protocol::FileChange;
|
||||
use llmx_core::protocol::McpToolCallBeginEvent;
|
||||
use llmx_core::protocol::McpToolCallEndEvent;
|
||||
use llmx_core::protocol::PatchApplyBeginEvent;
|
||||
use llmx_core::protocol::PatchApplyEndEvent;
|
||||
use llmx_core::protocol::SessionConfiguredEvent;
|
||||
use llmx_core::protocol::TaskCompleteEvent;
|
||||
use llmx_core::protocol::TaskStartedEvent;
|
||||
use llmx_core::protocol::WebSearchEndEvent;
|
||||
use llmx_protocol::plan_tool::StepStatus;
|
||||
use llmx_protocol::plan_tool::UpdatePlanArgs;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
pub struct EventProcessorWithJsonOutput {
|
||||
last_message_path: Option<PathBuf>,
|
||||
next_event_id: AtomicU64,
|
||||
// Tracks running commands by call_id, including the associated item id.
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
running_patch_applies: HashMap<String, PatchApplyBeginEvent>,
|
||||
// Tracks the todo list for the current turn (at most one per turn).
|
||||
running_todo_list: Option<RunningTodoList>,
|
||||
last_total_token_usage: Option<llmx_core::protocol::TokenUsage>,
|
||||
running_mcp_tool_calls: HashMap<String, RunningMcpToolCall>,
|
||||
last_critical_error: Option<ThreadErrorEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RunningCommand {
|
||||
command: String,
|
||||
item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RunningTodoList {
|
||||
item_id: String,
|
||||
items: Vec<TodoItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RunningMcpToolCall {
|
||||
server: String,
|
||||
tool: String,
|
||||
item_id: String,
|
||||
arguments: JsonValue,
|
||||
}
|
||||
|
||||
impl EventProcessorWithJsonOutput {
|
||||
pub fn new(last_message_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
last_message_path,
|
||||
next_event_id: AtomicU64::new(0),
|
||||
running_commands: HashMap::new(),
|
||||
running_patch_applies: HashMap::new(),
|
||||
running_todo_list: None,
|
||||
last_total_token_usage: None,
|
||||
running_mcp_tool_calls: HashMap::new(),
|
||||
last_critical_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect_thread_events(&mut self, event: &Event) -> Vec<ThreadEvent> {
|
||||
match &event.msg {
|
||||
EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev),
|
||||
EventMsg::AgentMessage(ev) => self.handle_agent_message(ev),
|
||||
EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev),
|
||||
EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev),
|
||||
EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev),
|
||||
EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev),
|
||||
EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev),
|
||||
EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev),
|
||||
EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev),
|
||||
EventMsg::WebSearchBegin(_) => Vec::new(),
|
||||
EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev),
|
||||
EventMsg::TokenCount(ev) => {
|
||||
if let Some(info) = &ev.info {
|
||||
self.last_total_token_usage = Some(info.total_token_usage.clone());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
EventMsg::TaskStarted(ev) => self.handle_task_started(ev),
|
||||
EventMsg::TaskComplete(_) => self.handle_task_complete(),
|
||||
EventMsg::Error(ev) => {
|
||||
let error = ThreadErrorEvent {
|
||||
message: ev.message.clone(),
|
||||
};
|
||||
self.last_critical_error = Some(error.clone());
|
||||
vec![ThreadEvent::Error(error)]
|
||||
}
|
||||
EventMsg::Warning(ev) => {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
details: ThreadItemDetails::Error(ErrorItem {
|
||||
message: ev.message.clone(),
|
||||
}),
|
||||
};
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent {
|
||||
message: ev.message.clone(),
|
||||
})],
|
||||
EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_next_item_id(&self) -> String {
|
||||
format!(
|
||||
"item_{}",
|
||||
self.next_event_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_session_configured(&self, payload: &SessionConfiguredEvent) -> Vec<ThreadEvent> {
|
||||
vec![ThreadEvent::ThreadStarted(ThreadStartedEvent {
|
||||
thread_id: payload.session_id.to_string(),
|
||||
})]
|
||||
}
|
||||
|
||||
fn handle_web_search_end(&self, ev: &WebSearchEndEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
details: ThreadItemDetails::WebSearch(WebSearchItem {
|
||||
query: ev.query.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
|
||||
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
|
||||
text: payload.message.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_reasoning_event(&self, ev: &AgentReasoningEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
|
||||
details: ThreadItemDetails::Reasoning(ReasoningItem {
|
||||
text: ev.text.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
fn handle_exec_command_begin(&mut self, ev: &ExecCommandBeginEvent) -> Vec<ThreadEvent> {
|
||||
let item_id = self.get_next_item_id();
|
||||
|
||||
let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) {
|
||||
Ok(command_string) => command_string,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
call_id = ev.call_id,
|
||||
"Failed to stringify command: {e:?}; skipping item.started"
|
||||
);
|
||||
ev.command.join(" ")
|
||||
}
|
||||
};
|
||||
|
||||
self.running_commands.insert(
|
||||
ev.call_id.clone(),
|
||||
RunningCommand {
|
||||
command: command_string.clone(),
|
||||
item_id: item_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command: command_string,
|
||||
aggregated_output: String::new(),
|
||||
exit_code: None,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_mcp_tool_call_begin(&mut self, ev: &McpToolCallBeginEvent) -> Vec<ThreadEvent> {
|
||||
let item_id = self.get_next_item_id();
|
||||
let server = ev.invocation.server.clone();
|
||||
let tool = ev.invocation.tool.clone();
|
||||
let arguments = ev.invocation.arguments.clone().unwrap_or(JsonValue::Null);
|
||||
|
||||
self.running_mcp_tool_calls.insert(
|
||||
ev.call_id.clone(),
|
||||
RunningMcpToolCall {
|
||||
server: server.clone(),
|
||||
tool: tool.clone(),
|
||||
item_id: item_id.clone(),
|
||||
arguments: arguments.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
||||
server,
|
||||
tool,
|
||||
arguments,
|
||||
result: None,
|
||||
error: None,
|
||||
status: McpToolCallStatus::InProgress,
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_mcp_tool_call_end(&mut self, ev: &McpToolCallEndEvent) -> Vec<ThreadEvent> {
|
||||
let status = if ev.is_success() {
|
||||
McpToolCallStatus::Completed
|
||||
} else {
|
||||
McpToolCallStatus::Failed
|
||||
};
|
||||
|
||||
let (server, tool, item_id, arguments) =
|
||||
match self.running_mcp_tool_calls.remove(&ev.call_id) {
|
||||
Some(running) => (
|
||||
running.server,
|
||||
running.tool,
|
||||
running.item_id,
|
||||
running.arguments,
|
||||
),
|
||||
None => {
|
||||
warn!(
|
||||
call_id = ev.call_id,
|
||||
"Received McpToolCallEnd without begin; synthesizing new item"
|
||||
);
|
||||
(
|
||||
ev.invocation.server.clone(),
|
||||
ev.invocation.tool.clone(),
|
||||
self.get_next_item_id(),
|
||||
ev.invocation.arguments.clone().unwrap_or(JsonValue::Null),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let (result, error) = match &ev.result {
|
||||
Ok(value) => {
|
||||
let result = McpToolCallItemResult {
|
||||
content: value.content.clone(),
|
||||
structured_content: value.structured_content.clone(),
|
||||
};
|
||||
(Some(result), None)
|
||||
}
|
||||
Err(message) => (
|
||||
None,
|
||||
Some(McpToolCallItemError {
|
||||
message: message.clone(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
||||
server,
|
||||
tool,
|
||||
arguments,
|
||||
result,
|
||||
error,
|
||||
status,
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_patch_apply_begin(&mut self, ev: &PatchApplyBeginEvent) -> Vec<ThreadEvent> {
|
||||
self.running_patch_applies
|
||||
.insert(ev.call_id.clone(), ev.clone());
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn map_change_kind(&self, kind: &FileChange) -> PatchChangeKind {
|
||||
match kind {
|
||||
FileChange::Add { .. } => PatchChangeKind::Add,
|
||||
FileChange::Delete { .. } => PatchChangeKind::Delete,
|
||||
FileChange::Update { .. } => PatchChangeKind::Update,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_patch_apply_end(&mut self, ev: &PatchApplyEndEvent) -> Vec<ThreadEvent> {
|
||||
if let Some(running_patch_apply) = self.running_patch_applies.remove(&ev.call_id) {
|
||||
let status = if ev.success {
|
||||
PatchApplyStatus::Completed
|
||||
} else {
|
||||
PatchApplyStatus::Failed
|
||||
};
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
|
||||
details: ThreadItemDetails::FileChange(FileChangeItem {
|
||||
changes: running_patch_apply
|
||||
.changes
|
||||
.iter()
|
||||
.map(|(path, change)| FileUpdateChange {
|
||||
path: path.to_str().unwrap_or("").to_string(),
|
||||
kind: self.map_change_kind(change),
|
||||
})
|
||||
.collect(),
|
||||
status,
|
||||
}),
|
||||
};
|
||||
|
||||
return vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })];
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec<ThreadEvent> {
|
||||
let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id)
|
||||
else {
|
||||
warn!(
|
||||
call_id = ev.call_id,
|
||||
"ExecCommandEnd without matching ExecCommandBegin; skipping item.completed"
|
||||
);
|
||||
return Vec::new();
|
||||
};
|
||||
let status = if ev.exit_code == 0 {
|
||||
CommandExecutionStatus::Completed
|
||||
} else {
|
||||
CommandExecutionStatus::Failed
|
||||
};
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command,
|
||||
aggregated_output: ev.aggregated_output.clone(),
|
||||
exit_code: Some(ev.exit_code),
|
||||
status,
|
||||
}),
|
||||
};
|
||||
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec<TodoItem> {
|
||||
args.plan
|
||||
.iter()
|
||||
.map(|p| TodoItem {
|
||||
text: p.step.clone(),
|
||||
completed: matches!(p.status, StepStatus::Completed),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec<ThreadEvent> {
|
||||
let items = self.todo_items_from_plan(args);
|
||||
|
||||
if let Some(running) = &mut self.running_todo_list {
|
||||
running.items = items.clone();
|
||||
let item = ThreadItem {
|
||||
id: running.item_id.clone(),
|
||||
details: ThreadItemDetails::TodoList(TodoListItem { items }),
|
||||
};
|
||||
return vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { item })];
|
||||
}
|
||||
|
||||
let item_id = self.get_next_item_id();
|
||||
self.running_todo_list = Some(RunningTodoList {
|
||||
item_id: item_id.clone(),
|
||||
items: items.clone(),
|
||||
});
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
details: ThreadItemDetails::TodoList(TodoListItem { items }),
|
||||
};
|
||||
vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_task_started(&mut self, _: &TaskStartedEvent) -> Vec<ThreadEvent> {
|
||||
self.last_critical_error = None;
|
||||
vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]
|
||||
}
|
||||
|
||||
fn handle_task_complete(&mut self) -> Vec<ThreadEvent> {
|
||||
let usage = if let Some(u) = &self.last_total_token_usage {
|
||||
Usage {
|
||||
input_tokens: u.input_tokens,
|
||||
cached_input_tokens: u.cached_input_tokens,
|
||||
output_tokens: u.output_tokens,
|
||||
}
|
||||
} else {
|
||||
Usage::default()
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(running) = self.running_todo_list.take() {
|
||||
let item = ThreadItem {
|
||||
id: running.item_id,
|
||||
details: ThreadItemDetails::TodoList(TodoListItem {
|
||||
items: running.items,
|
||||
}),
|
||||
};
|
||||
items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item }));
|
||||
}
|
||||
|
||||
if let Some(error) = self.last_critical_error.take() {
|
||||
items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error }));
|
||||
} else {
|
||||
items.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage }));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl EventProcessor for EventProcessorWithJsonOutput {
|
||||
fn print_config_summary(&mut self, _: &Config, _: &str, ev: &SessionConfiguredEvent) {
|
||||
self.process_event(Event {
|
||||
id: "".to_string(),
|
||||
msg: EventMsg::SessionConfigured(ev.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::print_stdout)]
|
||||
fn process_event(&mut self, event: Event) -> LlmxStatus {
|
||||
let aggregated = self.collect_thread_events(&event);
|
||||
for conv_event in aggregated {
|
||||
match serde_json::to_string(&conv_event) {
|
||||
Ok(line) => {
|
||||
println!("{line}");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize event: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Event { msg, .. } = event;
|
||||
|
||||
if let EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) = msg {
|
||||
if let Some(output_file) = self.last_message_path.as_deref() {
|
||||
handle_last_message(last_agent_message.as_deref(), output_file);
|
||||
}
|
||||
LlmxStatus::InitiateShutdown
|
||||
} else {
|
||||
LlmxStatus::Running
|
||||
}
|
||||
}
|
||||
}
|
||||
246
llmx-rs/exec/src/exec_events.rs
Normal file
246
llmx-rs/exec/src/exec_events.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use mcp_types::ContentBlock as McpContentBlock;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Top-level JSONL events emitted by llmx exec
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ThreadEvent {
|
||||
/// Emitted when a new thread is started as the first event.
|
||||
#[serde(rename = "thread.started")]
|
||||
ThreadStarted(ThreadStartedEvent),
|
||||
/// Emitted when a turn is started by sending a new prompt to the model.
|
||||
/// A turn encompasses all events that happen while agent is processing the prompt.
|
||||
#[serde(rename = "turn.started")]
|
||||
TurnStarted(TurnStartedEvent),
|
||||
/// Emitted when a turn is completed. Typically right after the assistant's response.
|
||||
#[serde(rename = "turn.completed")]
|
||||
TurnCompleted(TurnCompletedEvent),
|
||||
/// Indicates that a turn failed with an error.
|
||||
#[serde(rename = "turn.failed")]
|
||||
TurnFailed(TurnFailedEvent),
|
||||
/// Emitted when a new item is added to the thread. Typically the item will be in an "in progress" state.
|
||||
#[serde(rename = "item.started")]
|
||||
ItemStarted(ItemStartedEvent),
|
||||
/// Emitted when an item is updated.
|
||||
#[serde(rename = "item.updated")]
|
||||
ItemUpdated(ItemUpdatedEvent),
|
||||
/// Signals that an item has reached a terminal state—either success or failure.
|
||||
#[serde(rename = "item.completed")]
|
||||
ItemCompleted(ItemCompletedEvent),
|
||||
/// Represents an unrecoverable error emitted directly by the event stream.
|
||||
#[serde(rename = "error")]
|
||||
Error(ThreadErrorEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ThreadStartedEvent {
|
||||
/// The identified of the new thread. Can be used to resume the thread later.
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)]
|
||||
|
||||
pub struct TurnStartedEvent {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct TurnCompletedEvent {
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct TurnFailedEvent {
|
||||
pub error: ThreadErrorEvent,
|
||||
}
|
||||
|
||||
/// Describes the usage of tokens during a turn.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)]
|
||||
pub struct Usage {
|
||||
/// The number of input tokens used during the turn.
|
||||
pub input_tokens: i64,
|
||||
/// The number of cached input tokens used during the turn.
|
||||
pub cached_input_tokens: i64,
|
||||
/// The number of output tokens used during the turn.
|
||||
pub output_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ItemStartedEvent {
|
||||
pub item: ThreadItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ItemCompletedEvent {
|
||||
pub item: ThreadItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ItemUpdatedEvent {
|
||||
pub item: ThreadItem,
|
||||
}
|
||||
|
||||
/// Fatal error emitted by the stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ThreadErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Canonical representation of a thread item and its domain-specific payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ThreadItem {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub details: ThreadItemDetails,
|
||||
}
|
||||
|
||||
/// Typed payloads for each supported thread item type.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ThreadItemDetails {
|
||||
/// Response from the agent.
|
||||
/// Either a natural-language response or a JSON string when structured output is requested.
|
||||
AgentMessage(AgentMessageItem),
|
||||
/// Agent's reasoning summary.
|
||||
Reasoning(ReasoningItem),
|
||||
/// Tracks a command executed by the agent. The item starts when the command is
|
||||
/// spawned, and completes when the process exits with an exit code.
|
||||
CommandExecution(CommandExecutionItem),
|
||||
/// Represents a set of file changes by the agent. The item is emitted only as a
|
||||
/// completed event once the patch succeeds or fails.
|
||||
FileChange(FileChangeItem),
|
||||
/// Represents a call to an MCP tool. The item starts when the invocation is
|
||||
/// dispatched and completes when the MCP server reports success or failure.
|
||||
McpToolCall(McpToolCallItem),
|
||||
/// Captures a web search request. It starts when the search is kicked off
|
||||
/// and completes when results are returned to the agent.
|
||||
WebSearch(WebSearchItem),
|
||||
/// Tracks the agent's running to-do list. It starts when the plan is first
|
||||
/// issued, updates as steps change state, and completes when the turn ends.
|
||||
TodoList(TodoListItem),
|
||||
/// Describes a non-fatal error surfaced as an item.
|
||||
Error(ErrorItem),
|
||||
}
|
||||
|
||||
/// Response from the agent.
|
||||
/// Either a natural-language response or a JSON string when structured output is requested.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct AgentMessageItem {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Agent's reasoning summary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ReasoningItem {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The status of a command execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandExecutionStatus {
|
||||
#[default]
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A command executed by the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct CommandExecutionItem {
|
||||
pub command: String,
|
||||
pub aggregated_output: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub status: CommandExecutionStatus,
|
||||
}
|
||||
|
||||
/// A set of file changes by the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct FileUpdateChange {
|
||||
pub path: String,
|
||||
pub kind: PatchChangeKind,
|
||||
}
|
||||
|
||||
/// The status of a file change.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatchApplyStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A set of file changes by the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct FileChangeItem {
|
||||
pub changes: Vec<FileUpdateChange>,
|
||||
pub status: PatchApplyStatus,
|
||||
}
|
||||
|
||||
/// Indicates the type of the file change.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatchChangeKind {
|
||||
Add,
|
||||
Delete,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// The status of an MCP tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpToolCallStatus {
|
||||
#[default]
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Result payload produced by an MCP tool invocation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct McpToolCallItemResult {
|
||||
pub content: Vec<McpContentBlock>,
|
||||
pub structured_content: Option<JsonValue>,
|
||||
}
|
||||
|
||||
/// Error details reported by a failed MCP tool invocation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct McpToolCallItemError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A call to an MCP tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct McpToolCallItem {
|
||||
pub server: String,
|
||||
pub tool: String,
|
||||
#[serde(default)]
|
||||
pub arguments: JsonValue,
|
||||
pub result: Option<McpToolCallItemResult>,
|
||||
pub error: Option<McpToolCallItemError>,
|
||||
pub status: McpToolCallStatus,
|
||||
}
|
||||
|
||||
/// A web search request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct WebSearchItem {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// An error notification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct ErrorItem {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// An item in agent's to-do list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct TodoItem {
|
||||
pub text: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct TodoListItem {
|
||||
pub items: Vec<TodoItem>,
|
||||
}
|
||||
427
llmx-rs/exec/src/lib.rs
Normal file
427
llmx-rs/exec/src/lib.rs
Normal file
@@ -0,0 +1,427 @@
|
||||
// - In the default output mode, it is paramount that the only thing written to
|
||||
// stdout is the final message (if any).
|
||||
// - In --json mode, stdout must be valid JSONL, one event per line.
|
||||
// For both modes, any other output must be written to stderr.
|
||||
#![deny(clippy::print_stdout)]
|
||||
|
||||
mod cli;
|
||||
mod event_processor;
|
||||
mod event_processor_with_human_output;
|
||||
pub mod event_processor_with_jsonl_output;
|
||||
pub mod exec_events;
|
||||
|
||||
pub use cli::Cli;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_jsonl_output::EventProcessorWithJsonOutput;
|
||||
use llmx_core::AuthManager;
|
||||
use llmx_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use llmx_core::ConversationManager;
|
||||
use llmx_core::NewConversation;
|
||||
use llmx_core::auth::enforce_login_restrictions;
|
||||
use llmx_core::config::Config;
|
||||
use llmx_core::config::ConfigOverrides;
|
||||
use llmx_core::git_info::get_git_repo_root;
|
||||
use llmx_core::protocol::AskForApproval;
|
||||
use llmx_core::protocol::Event;
|
||||
use llmx_core::protocol::EventMsg;
|
||||
use llmx_core::protocol::Op;
|
||||
use llmx_core::protocol::SessionSource;
|
||||
use llmx_ollama::DEFAULT_OSS_MODEL;
|
||||
use llmx_protocol::config_types::SandboxMode;
|
||||
use llmx_protocol::user_input::UserInput;
|
||||
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||
use serde_json::Value;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::cli::Command as ExecCommand;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::LlmxStatus;
|
||||
use llmx_core::default_client::set_default_originator;
|
||||
use llmx_core::find_conversation_path_by_id_str;
|
||||
|
||||
pub async fn run_main(cli: Cli, llmx_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
if let Err(err) = set_default_originator("llmx_exec".to_string()) {
|
||||
tracing::warn!(?err, "Failed to set llmx exec originator override {err:?}");
|
||||
}
|
||||
|
||||
let Cli {
|
||||
command,
|
||||
images,
|
||||
model: model_cli_arg,
|
||||
oss,
|
||||
config_profile,
|
||||
full_auto,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
skip_git_repo_check,
|
||||
color,
|
||||
last_message_file,
|
||||
json: json_mode,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt,
|
||||
output_schema: output_schema_path,
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
|
||||
let prompt_arg = match &command {
|
||||
// Allow prompt before the subcommand by falling back to the parent-level prompt
|
||||
// when the Resume subcommand did not provide its own prompt.
|
||||
Some(ExecCommand::Resume(args)) => args.prompt.clone().or(prompt),
|
||||
None => prompt,
|
||||
};
|
||||
|
||||
let prompt = match prompt_arg {
|
||||
Some(p) if p != "-" => p,
|
||||
// Either `-` was passed or no positional arg.
|
||||
maybe_dash => {
|
||||
// When no arg (None) **and** stdin is a TTY, bail out early – unless the
|
||||
// user explicitly forced reading via `-`.
|
||||
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
|
||||
|
||||
if std::io::stdin().is_terminal() && !force_stdin {
|
||||
eprintln!(
|
||||
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Ensure the user knows we are waiting on stdin, as they may
|
||||
// have gotten into this state by mistake. If so, and they are not
|
||||
// writing to stdin, LLMX will hang indefinitely, so this should
|
||||
// help them debug in that case.
|
||||
if !force_stdin {
|
||||
eprintln!("Reading prompt from stdin...");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
|
||||
eprintln!("Failed to read prompt from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
} else if buffer.trim().is_empty() {
|
||||
eprintln!("No prompt provided via stdin.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
};
|
||||
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
|
||||
let (stdout_with_ansi, stderr_with_ansi) = match color {
|
||||
cli::Color::Always => (true, true),
|
||||
cli::Color::Never => (false, false),
|
||||
cli::Color::Auto => (
|
||||
supports_color::on_cached(Stream::Stdout).is_some(),
|
||||
supports_color::on_cached(Stream::Stderr).is_some(),
|
||||
),
|
||||
};
|
||||
|
||||
// Build fmt layer (existing logging) to compose with OTEL layer.
|
||||
let default_level = "error";
|
||||
|
||||
// Build env_filter separately and attach via with_filter.
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new(default_level))
|
||||
.unwrap_or_else(|_| EnvFilter::new(default_level));
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(stderr_with_ansi)
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(env_filter);
|
||||
|
||||
let sandbox_mode = if full_auto {
|
||||
Some(SandboxMode::WorkspaceWrite)
|
||||
} else if dangerously_bypass_approvals_and_sandbox {
|
||||
Some(SandboxMode::DangerFullAccess)
|
||||
} else {
|
||||
sandbox_mode_cli_arg.map(Into::<SandboxMode>::into)
|
||||
};
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model (defaulting to
|
||||
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
|
||||
// `oss` model provider.
|
||||
let model = if let Some(model) = model_cli_arg {
|
||||
Some(model)
|
||||
} else if oss {
|
||||
Some(DEFAULT_OSS_MODEL.to_owned())
|
||||
} else {
|
||||
None // No model specified, will use the default.
|
||||
};
|
||||
|
||||
let model_provider = if oss {
|
||||
Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_string())
|
||||
} else {
|
||||
None // No specific model provider override.
|
||||
};
|
||||
|
||||
// Load configuration and determine approval policy
|
||||
let overrides = ConfigOverrides {
|
||||
model,
|
||||
review_model: None,
|
||||
config_profile,
|
||||
// Default to never ask for approvals in headless mode. Feature flags can override.
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_mode,
|
||||
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
|
||||
model_provider,
|
||||
llmx_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
experimental_sandbox_command_assessment: None,
|
||||
additional_writable_roots: Vec::new(),
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
|
||||
|
||||
if let Err(err) = enforce_login_restrictions(&config).await {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let otel = llmx_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[allow(clippy::print_stderr)]
|
||||
let otel = match otel {
|
||||
Ok(otel) => otel,
|
||||
Err(e) => {
|
||||
eprintln!("Could not create otel exporter: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(provider) = otel.as_ref() {
|
||||
let otel_layer = OpenTelemetryTracingBridge::new(&provider.logger).with_filter(
|
||||
tracing_subscriber::filter::filter_fn(llmx_core::otel_init::llmx_export_filter),
|
||||
);
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(otel_layer)
|
||||
.try_init();
|
||||
} else {
|
||||
let _ = tracing_subscriber::registry().with(fmt_layer).try_init();
|
||||
}
|
||||
|
||||
let mut event_processor: Box<dyn EventProcessor> = match json_mode {
|
||||
true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())),
|
||||
_ => Box::new(EventProcessorWithHumanOutput::create_with_ansi(
|
||||
stdout_with_ansi,
|
||||
&config,
|
||||
last_message_file.clone(),
|
||||
)),
|
||||
};
|
||||
|
||||
if oss {
|
||||
llmx_ollama::ensure_oss_ready(&config)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?;
|
||||
}
|
||||
|
||||
let default_cwd = config.cwd.to_path_buf();
|
||||
let default_approval_policy = config.approval_policy;
|
||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let default_model = config.model.clone();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() {
|
||||
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.llmx_home.clone(),
|
||||
true,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
|
||||
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
session_configured,
|
||||
} = if let Some(ExecCommand::Resume(args)) = command {
|
||||
let resume_path = resolve_resume_path(&config, &args).await?;
|
||||
|
||||
if let Some(path) = resume_path {
|
||||
conversation_manager
|
||||
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
|
||||
.await?
|
||||
} else {
|
||||
conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
};
|
||||
// Print the effective configuration and prompt so users can see what LLMX
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt, &session_configured);
|
||||
|
||||
info!("LLMX initialized with event: {session_configured:?}");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
{
|
||||
let conversation = conversation.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::debug!("Keyboard interrupt");
|
||||
// Immediately notify LLMX to abort any in‑flight task.
|
||||
conversation.submit(Op::Interrupt).await.ok();
|
||||
|
||||
// Exit the inner loop and return to the main input prompt. The llmx
|
||||
// will emit a `TurnInterrupted` (Error) event which is drained later.
|
||||
break;
|
||||
}
|
||||
res = conversation.next_event() => match res {
|
||||
Ok(event) => {
|
||||
debug!("Received event: {event:?}");
|
||||
|
||||
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
|
||||
if let Err(e) = tx.send(event) {
|
||||
error!("Error sending event: {e:?}");
|
||||
break;
|
||||
}
|
||||
if is_shutdown_complete {
|
||||
info!("Received shutdown event, exiting event loop.");
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error receiving event: {e:?}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Package images and prompt into a single user input turn.
|
||||
let mut items: Vec<UserInput> = images
|
||||
.into_iter()
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text { text: prompt });
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
// Track whether a fatal error was reported by the server so we can
|
||||
// exit with a non-zero status for automation-friendly signaling.
|
||||
let mut error_seen = false;
|
||||
while let Some(event) = rx.recv().await {
|
||||
if matches!(event.msg, EventMsg::Error(_)) {
|
||||
error_seen = true;
|
||||
}
|
||||
let shutdown: LlmxStatus = event_processor.process_event(event);
|
||||
match shutdown {
|
||||
LlmxStatus::Running => continue,
|
||||
LlmxStatus::InitiateShutdown => {
|
||||
conversation.submit(Op::Shutdown).await?;
|
||||
}
|
||||
LlmxStatus::Shutdown => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
event_processor.print_final_output();
|
||||
if error_seen {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_resume_path(
|
||||
config: &Config,
|
||||
args: &crate::cli::ResumeArgs,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
if args.last {
|
||||
let default_provider_filter = vec![config.model_provider_id.clone()];
|
||||
match llmx_core::RolloutRecorder::list_conversations(
|
||||
&config.llmx_home,
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
Some(default_provider_filter.as_slice()),
|
||||
&config.model_provider_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
|
||||
Err(e) => {
|
||||
error!("Error listing conversations: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
} else if let Some(id_str) = args.session_id.as_deref() {
|
||||
let path = find_conversation_path_by_id_str(&config.llmx_home, id_str).await?;
|
||||
Ok(path)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
let path = path?;
|
||||
|
||||
let schema_str = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Failed to read output schema file {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<Value>(&schema_str) {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Output schema file {} is not valid JSON: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
llmx-rs/exec/src/main.rs
Normal file
40
llmx-rs/exec/src/main.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Entry-point for the `llmx-exec` binary.
|
||||
//!
|
||||
//! When this CLI is invoked normally, it parses the standard `llmx-exec` CLI
|
||||
//! options and launches the non-interactive LLMX agent. However, if it is
|
||||
//! invoked with arg0 as `llmx-linux-sandbox`, we instead treat the invocation
|
||||
//! as a request to run the logic for the standalone `llmx-linux-sandbox`
|
||||
//! executable (i.e., parse any -s args and then run a *sandboxed* command under
|
||||
//! Landlock + seccomp.
|
||||
//!
|
||||
//! This allows us to ship a completely separate set of functionality as part
|
||||
//! of the `llmx-exec` binary.
|
||||
use clap::Parser;
|
||||
use llmx_arg0::arg0_dispatch_or_else;
|
||||
use llmx_common::CliConfigOverrides;
|
||||
use llmx_exec::Cli;
|
||||
use llmx_exec::run_main;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct TopCli {
|
||||
#[clap(flatten)]
|
||||
config_overrides: CliConfigOverrides,
|
||||
|
||||
#[clap(flatten)]
|
||||
inner: Cli,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|llmx_linux_sandbox_exe| async move {
|
||||
let top_cli = TopCli::parse();
|
||||
// Merge root-level overrides into inner CLI struct so downstream logic remains unchanged.
|
||||
let mut inner = top_cli.inner;
|
||||
inner
|
||||
.config_overrides
|
||||
.raw_overrides
|
||||
.splice(0..0, top_cli.config_overrides.raw_overrides);
|
||||
|
||||
run_main(inner, llmx_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user