Skip to content

Commit 7d17e8b

Browse files
committed
feat(ux): 4-phase extreme UX improvement
Phase 1 — Smart tool tiering: - Add ToolTier (Core/Standard/Extended) and ToolCategory enums - Annotate all 40+ tools with tier and category classifications - Add tier()/categories() default methods to Tool trait Phase 2 — Zero-config startup (prx go): - New `prx go` subcommand for instant start with -k flag - File-based credential detection (auth-profiles, config.toml, Claude OAuth) - Provider/model auto-inference from API key prefix Phase 3 — Structured error experience: - Did-you-mean suggestions for unknown tool names (strsim) - Schema-aware missing parameter formatting - Context-sensitive recovery hints for common errors - Repeated failure detection with guidance injection Phase 4 — Perception enhancement: - Dynamic braille spinner during LLM thinking - Tool execution duration display (e.g. "shell (1.2s)") - Multi-step progress indicators for tool iterations
1 parent 9fe8ab5 commit 7d17e8b

59 files changed

Lines changed: 1427 additions & 88 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

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

Cargo.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ resolver = "2"
44

55
[package]
66
name = "openprx"
7-
version = "0.3.0"
7+
version = "0.3.1"
88
edition = "2024"
99
authors = ["g1e2x87"]
1010
license = "MIT OR Apache-2.0"
@@ -69,6 +69,9 @@ fantoccini = { version = "0.22.0", optional = true, default-features = false, fe
6969
anyhow = "1.0"
7070
thiserror = "2.0"
7171

72+
# String similarity for tool name suggestions
73+
strsim = "0.11"
74+
7275
# UUID generation
7376
uuid = { version = "1.11", default-features = false, features = ["v4", "v7", "std"] }
7477

@@ -177,6 +180,9 @@ probe-rs = { version = "0.31", optional = true }
177180
pdf-extract = { version = "0.10", optional = true }
178181
tokio-stream = { version = "0.1.18", features = ["full"] }
179182

183+
# Temporary directory for zero-config `prx go` workspace
184+
tempfile = "3.14"
185+
180186
# WhatsApp Web client (wa-rs) — optional, enable with --features whatsapp-web
181187
# Uses wa-rs for Bot and Client, wa-rs-core for storage traits, custom rusqlite backend avoids Diesel conflict.
182188
wa-rs = { version = "0.2", optional = true, default-features = false }
@@ -247,7 +253,6 @@ strip = true
247253
panic = "abort"
248254

249255
[dev-dependencies]
250-
tempfile = "3.14"
251256

252257
[workspace.lints.rust]
253258
unsafe_code = "deny"

src/agent/agent.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,24 @@ impl Agent {
519519
let output = if r.success {
520520
r.output
521521
} else {
522-
format!("Error: {}", r.error.unwrap_or(r.output))
522+
let error_text = r.error.unwrap_or(r.output);
523+
let hint = crate::tools::error_hints::recovery_hint(&call.name, &error_text);
524+
if hint.is_empty() {
525+
format!("Error: {error_text}")
526+
} else {
527+
format!("Error: {error_text}\n{hint}")
528+
}
523529
};
524530
(output, actual_success)
525531
}
526532
Err(e) => {
527-
let message = format!("Error executing {}: {e}", call.name);
533+
let error_str = e.to_string();
534+
let hint = crate::tools::error_hints::recovery_hint(&call.name, &error_str);
535+
let message = if hint.is_empty() {
536+
format!("Error executing {}: {error_str}", call.name)
537+
} else {
538+
format!("Error executing {}: {error_str}\n{hint}", call.name)
539+
};
528540
self.hooks.emit(HookEvent::Error, payload_error("tool", &message)).await;
529541
self.observer.record_event(&ObserverEvent::ToolCall {
530542
tool: call.name.clone(),
@@ -535,7 +547,14 @@ impl Agent {
535547
}
536548
}
537549
} else {
538-
let message = format!("Unknown tool: {}", call.name);
550+
let available_names: Vec<&str> = self.tools.iter().map(|t| t.name()).collect();
551+
let suggestion = crate::tools::error_hints::suggest_tool_name(&call.name, &available_names);
552+
let hint = suggestion.map(|s| format!(" Did you mean '{s}'?")).unwrap_or_default();
553+
let message = format!(
554+
"Error: unknown tool '{}'.{hint}\nAvailable tools: {}",
555+
call.name,
556+
available_names.join(", ")
557+
);
539558
self.hooks.emit(HookEvent::Error, payload_error("tool", &message)).await;
540559
(message, false)
541560
};

src/agent/loop_.rs

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ pub(crate) enum ToolCallNotification {
3131
/// A tool call is about to be executed.
3232
Started { name: String, args_summary: String },
3333
/// A tool call has finished executing.
34-
Finished { name: String, success: bool },
34+
Finished {
35+
name: String,
36+
success: bool,
37+
duration_ms: u64,
38+
},
39+
/// Progress indication for multi-iteration tool loops.
40+
Progress { iteration: usize, max_iterations: usize },
3541
}
3642

3743
/// Context for scope-based tool access control.
@@ -1599,7 +1605,13 @@ async fn execute_one_tool(
15991605
scope_ctx: Option<&ScopeContext<'_>>,
16001606
) -> Result<String> {
16011607
let Some(tool) = find_tool(tools_registry, call_name) else {
1602-
return Ok(format!("Unknown tool: {call_name}"));
1608+
let available_names: Vec<&str> = tools_registry.iter().map(|t| t.name()).collect();
1609+
let suggestion = crate::tools::error_hints::suggest_tool_name(call_name, &available_names);
1610+
let hint = suggestion.map(|s| format!(" Did you mean '{s}'?")).unwrap_or_default();
1611+
return Ok(format!(
1612+
"Error: unknown tool '{call_name}'.{hint}\nAvailable tools: {}",
1613+
available_names.join(", ")
1614+
));
16031615
};
16041616

16051617
let root = call_arguments
@@ -1639,9 +1651,8 @@ async fn execute_one_tool(
16391651
.filter(|key| !args_obj.contains_key(*key))
16401652
.collect();
16411653
if !missing.is_empty() {
1642-
return Ok(format!(
1643-
"Error: missing required argument(s) for tool '{call_name}': {}",
1644-
missing.join(", ")
1654+
return Ok(crate::tools::error_hints::format_missing_params(
1655+
call_name, &missing, &schema,
16451656
));
16461657
}
16471658
}
@@ -1672,7 +1683,13 @@ async fn execute_one_tool(
16721683
if r.success {
16731684
Ok(scrub_credentials(&r.output))
16741685
} else {
1675-
Ok(format!("Error: {}", r.error.unwrap_or_else(|| r.output)))
1686+
let error_text = r.error.unwrap_or_else(|| r.output);
1687+
let hint = crate::tools::error_hints::recovery_hint(call_name, &error_text);
1688+
if hint.is_empty() {
1689+
Ok(format!("Error: {error_text}"))
1690+
} else {
1691+
Ok(format!("Error: {error_text}\n{hint}"))
1692+
}
16761693
}
16771694
}
16781695
Err(e) => {
@@ -1681,7 +1698,13 @@ async fn execute_one_tool(
16811698
duration: start.elapsed(),
16821699
success: false,
16831700
});
1684-
Ok(format!("Error executing {call_name}: {e}"))
1701+
let error_str = e.to_string();
1702+
let hint = crate::tools::error_hints::recovery_hint(call_name, &error_str);
1703+
if hint.is_empty() {
1704+
Ok(format!("Error executing {call_name}: {error_str}"))
1705+
} else {
1706+
Ok(format!("Error executing {call_name}: {error_str}\n{hint}"))
1707+
}
16851708
}
16861709
}
16871710
}
@@ -2280,8 +2303,19 @@ pub(crate) async fn run_tool_call_loop(
22802303
}
22812304

22822305
let mut overflow_retries: usize = 0;
2306+
let mut consecutive_failures: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2307+
2308+
for iteration in 0..max_iterations {
2309+
// Notify progress for multi-iteration tool loops (skip the first iteration).
2310+
if iteration > 0 {
2311+
if let Some(ref tx) = on_tool_call {
2312+
let _ = tx.try_send(ToolCallNotification::Progress {
2313+
iteration: iteration + 1,
2314+
max_iterations,
2315+
});
2316+
}
2317+
}
22832318

2284-
for _iteration in 0..max_iterations {
22852319
if cancellation_token.as_ref().is_some_and(CancellationToken::is_cancelled) {
22862320
return Err(ToolLoopCancelled.into());
22872321
}
@@ -2573,6 +2607,7 @@ pub(crate) async fn run_tool_call_loop(
25732607
}
25742608
}
25752609

2610+
let tools_started_at = Instant::now();
25762611
let individual_results = execute_tools_with_policy(
25772612
&tool_calls,
25782613
tools_registry,
@@ -2584,6 +2619,7 @@ pub(crate) async fn run_tool_call_loop(
25842619
scope_ctx,
25852620
)
25862621
.await?;
2622+
let tools_elapsed_ms = tools_started_at.elapsed().as_millis() as u64;
25872623

25882624
for (call, result) in tool_calls.iter().zip(individual_results.iter()) {
25892625
let success = !result.starts_with("Error");
@@ -2602,18 +2638,37 @@ pub(crate) async fn run_tool_call_loop(
26022638
.send(ToolCallNotification::Finished {
26032639
name: call.name.clone(),
26042640
success,
2641+
duration_ms: tools_elapsed_ms,
26052642
})
26062643
.await;
26072644
}
26082645
if !success {
26092646
hooks.emit(HookEvent::Error, payload_error("tool", result)).await;
26102647
}
2648+
2649+
// Track consecutive failures per tool for repeated-failure hints.
2650+
let repeated_hint = if success {
2651+
consecutive_failures.remove(&call.name);
2652+
String::new()
2653+
} else {
2654+
let count = consecutive_failures.entry(call.name.clone()).or_insert(0);
2655+
*count += 1;
2656+
if *count >= 2 {
2657+
format!(
2658+
"\n[System: tool '{}' has failed {} times consecutively. Consider a different approach or tool.]",
2659+
call.name, count
2660+
)
2661+
} else {
2662+
String::new()
2663+
}
2664+
};
2665+
26112666
// P0-2: Truncate oversized tool results before inserting into history.
26122667
let truncated_result = truncate_tool_result_if_needed(result, MAX_TOOL_RESULT_CHARS);
26132668
let _ = writeln!(
26142669
tool_results,
2615-
"<tool_result name=\"{}\">\n{}\n</tool_result>",
2616-
call.name, truncated_result
2670+
"<tool_result name=\"{}\">\n{}{}\n</tool_result>",
2671+
call.name, truncated_result, repeated_hint
26172672
);
26182673
}
26192674

src/agent/tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,12 +523,12 @@ async fn turn_handles_unknown_tool_gracefully() {
523523
"Expected non-empty response after unknown tool recovery"
524524
);
525525

526-
// Verify the tool result mentioned "Unknown tool"
526+
// Verify the tool result mentioned "unknown tool"
527527
let has_tool_result = agent.history().iter().any(|msg| match msg {
528-
ConversationMessage::ToolResults(results) => results.iter().any(|r| r.content.contains("Unknown tool")),
528+
ConversationMessage::ToolResults(results) => results.iter().any(|r| r.content.contains("unknown tool")),
529529
_ => false,
530530
});
531-
assert!(has_tool_result, "Expected tool result with 'Unknown tool' message");
531+
assert!(has_tool_result, "Expected tool result with 'unknown tool' message");
532532
}
533533

534534
// ═══════════════════════════════════════════════════════════════════════════

src/channels/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2250,15 +2250,32 @@ async fn process_channel_message(
22502250
"Tool call started"
22512251
);
22522252
}
2253-
crate::agent::loop_::ToolCallNotification::Finished { name, success } => {
2253+
crate::agent::loop_::ToolCallNotification::Finished {
2254+
name,
2255+
success,
2256+
duration_ms,
2257+
} => {
22542258
tracing::info!(
22552259
channel = %tool_event_channel_name,
22562260
sender = %tool_event_sender_name,
22572261
tool = %name,
22582262
success,
2263+
duration_ms,
22592264
"Tool call finished"
22602265
);
22612266
}
2267+
crate::agent::loop_::ToolCallNotification::Progress {
2268+
iteration,
2269+
max_iterations,
2270+
} => {
2271+
tracing::info!(
2272+
channel = %tool_event_channel_name,
2273+
sender = %tool_event_sender_name,
2274+
iteration,
2275+
max_iterations,
2276+
"Tool loop progress"
2277+
);
2278+
}
22622279
}
22632280
}
22642281
});

0 commit comments

Comments
 (0)