Harden local tool execution
This commit is contained in:
26
PLAN.md
26
PLAN.md
@@ -27,6 +27,13 @@ execution targets one self-contained Mac.
|
||||
starting tool set, unlimited tool rounds, queued user guidance between tool
|
||||
rounds, session date/time context, periodic tool-contract reminders,
|
||||
cooperative Stop, and explicit activity/failure states are implemented.
|
||||
- Local tools are hardened for daily use: canonical project boundaries reject
|
||||
parent and symlink escapes, shell commands receive a deliberate environment,
|
||||
risky shell and visible-browser actions share one cancellable Allow once/Deny
|
||||
approval path, and compact tool cards expose bounded parameters, results, and
|
||||
parsing/approval/queue/run/completion lifecycle state without showing DSML.
|
||||
Background jobs and bounded output files stop and clean up with Stop, session
|
||||
switches, and application shutdown.
|
||||
- Context compaction uses the reference soft and exact token-counted hard
|
||||
triggers, private live-model summaries, bounded summary and tool-result
|
||||
retries, a recent verbatim tail, running-job observations, and compatible KV
|
||||
@@ -38,21 +45,20 @@ execution targets one self-contained Mac.
|
||||
- Focused coverage exercises triggers, summary bounds and sanitizing, tail
|
||||
selection, queued guidance, checkpoint identity, running jobs, durable
|
||||
compaction markers, relaunch, and continued tool work after rebuild.
|
||||
- The next baseline gap is tool hardening and safety. SSD streaming,
|
||||
speculative decoding, steering, GLM 5.2 execution, and DeepSeek V4 Pro
|
||||
execution are not implemented in the Rust executor. Related catalog,
|
||||
validation, and preference plumbing must not be treated as runtime support.
|
||||
- The next baseline gap is SSD streaming. Speculative decoding, steering, GLM
|
||||
5.2 execution, and DeepSeek V4 Pro execution are not implemented in the Rust
|
||||
executor. Related catalog, validation, and preference plumbing must not be
|
||||
treated as runtime support.
|
||||
|
||||
## Delivery order
|
||||
|
||||
1. **Next:** tool hardening, approvals, and productive tool presentation.
|
||||
2. Remaining DS4 execution technology, starting with SSD streaming, then
|
||||
1. **Next:** remaining DS4 execution technology, starting with SSD streaming, then
|
||||
speculative decoding and the other Metal/runtime parity work.
|
||||
3. Additional model execution: GLM 5.2 and DeepSeek V4 Pro.
|
||||
4. Product completion, exhaustive parity verification, and distribution.
|
||||
5. Optional extensions: Dev Brain and A2UI.
|
||||
2. Additional model execution: GLM 5.2 and DeepSeek V4 Pro.
|
||||
3. Product completion, exhaustive parity verification, and distribution.
|
||||
4. Optional extensions: Dev Brain and A2UI.
|
||||
|
||||
## 1. Next — tool hardening and safety
|
||||
## 1. Completed — tool hardening and safety
|
||||
|
||||
Goal: make the existing tool set safe and clear enough for productive daily
|
||||
use without weakening its ability to inspect, edit, build, and test a project.
|
||||
|
||||
815
src/agent.rs
815
src/agent.rs
File diff suppressed because it is too large
Load Diff
50
src/app.rs
50
src/app.rs
@@ -103,6 +103,11 @@ pub(crate) struct App {
|
||||
#[cfg(target_os = "macos")]
|
||||
active_tools: Option<crate::agent::ActiveTools>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) tool_cards: Vec<crate::agent::ToolCard>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) pending_tool_approval:
|
||||
Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
|
||||
#[cfg(target_os = "macos")]
|
||||
active_titling: Option<generation::TitleRequest>,
|
||||
#[cfg(target_os = "macos")]
|
||||
runtime_config: Arc<RwLock<Config>>,
|
||||
@@ -199,6 +204,10 @@ pub(crate) enum Message {
|
||||
ComposerChanged(String),
|
||||
ToggleReasoning(usize),
|
||||
OpenLink(markdown::Url),
|
||||
CopyToolText(String),
|
||||
OpenToolOutput(PathBuf),
|
||||
AllowToolOnce,
|
||||
DenyTool,
|
||||
SubmitPrompt,
|
||||
StopGeneration,
|
||||
GenerationTick,
|
||||
@@ -316,6 +325,10 @@ impl App {
|
||||
#[cfg(target_os = "macos")]
|
||||
active_tools: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
tool_cards: Vec::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_tool_approval: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
active_titling: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
runtime_config,
|
||||
@@ -415,6 +428,10 @@ impl App {
|
||||
#[cfg(target_os = "macos")]
|
||||
active_tools: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
tool_cards: Vec::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_tool_approval: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
active_titling: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
runtime_config,
|
||||
@@ -778,6 +795,25 @@ impl App {
|
||||
self.error = Some(format!("Could not open the link: {error}"));
|
||||
}
|
||||
}
|
||||
Message::CopyToolText(value) => return iced::clipboard::write(value),
|
||||
Message::OpenToolOutput(path) => {
|
||||
if let Err(error) = std::process::Command::new("open").arg(path).spawn() {
|
||||
self.error = Some(format!("Could not open the tool output: {error}"));
|
||||
}
|
||||
}
|
||||
Message::AllowToolOnce => {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((_, decision)) = self.pending_tool_approval.take() {
|
||||
let _ = decision.send(true);
|
||||
}
|
||||
}
|
||||
Message::DenyTool =>
|
||||
{
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((_, decision)) = self.pending_tool_approval.take() {
|
||||
let _ = decision.send(false);
|
||||
}
|
||||
}
|
||||
Message::SubmitPrompt => {
|
||||
self.start_generation();
|
||||
return scroll_chat_to_end();
|
||||
@@ -794,6 +830,12 @@ impl App {
|
||||
active.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((_, decision)) = self.pending_tool_approval.take() {
|
||||
let _ = decision.send(false);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
self.stop_agent_jobs();
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(compaction) = &self.active_compaction {
|
||||
compaction.active.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1019,6 +1061,10 @@ impl App {
|
||||
if self.selected_session == Some(session_id) {
|
||||
return Task::none();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
self.stop_agent_jobs();
|
||||
#[cfg(target_os = "macos")]
|
||||
self.tool_cards.clear();
|
||||
let saved_context = self
|
||||
.projects
|
||||
.iter()
|
||||
@@ -1298,6 +1344,10 @@ impl Drop for App {
|
||||
if let Some(active) = &self.active_tools {
|
||||
active.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((_, decision)) = self.pending_tool_approval.take() {
|
||||
let _ = decision.send(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,8 @@ impl App {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
self.tool_cards.clear();
|
||||
#[cfg(target_os = "macos")]
|
||||
if !std::mem::take(&mut self.skip_compaction_once)
|
||||
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
||||
{
|
||||
@@ -388,11 +390,47 @@ impl App {
|
||||
return self.poll_tool_result_check();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.active_tools.is_some() {
|
||||
while let Some(event) = self
|
||||
.active_tools
|
||||
.as_ref()
|
||||
.and_then(crate::agent::try_tool_event)
|
||||
{
|
||||
match event {
|
||||
crate::agent::ToolEvent::State {
|
||||
index,
|
||||
state,
|
||||
result,
|
||||
} => {
|
||||
if let Some(card) = self.tool_cards.get_mut(index) {
|
||||
card.state = state;
|
||||
if result.is_some() {
|
||||
card.result = result;
|
||||
}
|
||||
}
|
||||
self.activity = Some(format!("Tool {} · {}", index + 1, state.label()));
|
||||
}
|
||||
crate::agent::ToolEvent::Approval {
|
||||
index,
|
||||
prompt,
|
||||
decision,
|
||||
} => {
|
||||
if let Some(card) = self.tool_cards.get_mut(index) {
|
||||
card.state = crate::agent::ToolLifecycle::AwaitingApproval;
|
||||
}
|
||||
self.pending_tool_approval = Some((prompt, decision));
|
||||
self.activity = Some(format!("Tool {} · Awaiting approval", index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(active) = &self.active_tools {
|
||||
match crate::agent::try_tool_result(active) {
|
||||
Ok(Some(result)) => {
|
||||
let cancelled = active.cancel.load(Ordering::Relaxed);
|
||||
self.active_tools = None;
|
||||
self.pending_tool_approval = None;
|
||||
if cancelled {
|
||||
self.generating = false;
|
||||
self.activity = Some("Stopped".into());
|
||||
@@ -453,6 +491,13 @@ impl App {
|
||||
&& !message.user
|
||||
{
|
||||
message.append(reasoning, &content);
|
||||
if !reasoning
|
||||
&& self.tool_cards.is_empty()
|
||||
&& crate::agent::has_tool_markup(&message.content)
|
||||
{
|
||||
self.tool_cards.push(crate::agent::ToolCard::streaming());
|
||||
self.activity = Some("Parsing tool call…".into());
|
||||
}
|
||||
transcript_changed = true;
|
||||
}
|
||||
}
|
||||
@@ -501,6 +546,7 @@ impl App {
|
||||
Ok(_) => {
|
||||
self.generating = false;
|
||||
self.activity = None;
|
||||
self.tool_cards.clear();
|
||||
start_queued = !self.queued_inputs.is_empty()
|
||||
|| self.manual_compaction_queued;
|
||||
}
|
||||
@@ -628,8 +674,13 @@ impl App {
|
||||
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
|
||||
}
|
||||
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
|
||||
self.tool_cards = calls
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(crate::agent::ToolCard::parsing)
|
||||
.collect();
|
||||
self.active_tools = Some(crate::agent::execute_async(tools, calls));
|
||||
self.activity = Some("Running tools…".into());
|
||||
self.activity = Some("Parsing tool calls…".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -685,6 +736,7 @@ impl App {
|
||||
.collect();
|
||||
assistant.reasoning_open = assistant_reasoning;
|
||||
self.conversation.push(assistant);
|
||||
self.tool_cards.clear();
|
||||
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||
self.active_generation = Some(
|
||||
self.generation_service
|
||||
@@ -704,6 +756,20 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn stop_agent_jobs(&mut self) {
|
||||
let Some((_, tools)) = &self.agent_tools else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut tools) = tools.try_lock() else {
|
||||
return;
|
||||
};
|
||||
let failures = tools.stop_all_jobs();
|
||||
if !failures.is_empty() {
|
||||
self.error = Some(failures.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_tool_result_check(
|
||||
&mut self,
|
||||
|
||||
@@ -69,6 +69,10 @@ impl App {
|
||||
if self.database.is_none() {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
self.stop_agent_jobs();
|
||||
#[cfg(target_os = "macos")]
|
||||
self.tool_cards.clear();
|
||||
let title = draft_title(&self.projects, project_id);
|
||||
self.drafts.entry(project_id).or_insert(title);
|
||||
self.remember_project(project_id);
|
||||
|
||||
@@ -69,6 +69,16 @@ impl App {
|
||||
|| self.pending_project_path.is_some()
|
||||
|| self.session_rename.is_some()
|
||||
|| self.menu_session().is_some()
|
||||
|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.pending_tool_approval.is_some()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main_view(&self) -> Element<'_, Message> {
|
||||
@@ -112,6 +122,19 @@ impl App {
|
||||
let content: Element<'_, Message> = shell.into();
|
||||
let mut layers = vec![content];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((prompt, _)) = &self.pending_tool_approval {
|
||||
layers.push(self.tool_approval_panel(prompt));
|
||||
} else if self.preferences_open {
|
||||
layers.push(self.preferences_panel());
|
||||
} else if let Some(path) = &self.pending_project_path {
|
||||
layers.push(self.project_dialog(path));
|
||||
} else if let Some((_, title)) = &self.session_rename {
|
||||
layers.push(self.rename_dialog(title));
|
||||
} else if let Some(session) = self.menu_session() {
|
||||
layers.push(self.session_menu_panel(session));
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.preferences_open {
|
||||
layers.push(self.preferences_panel());
|
||||
} else if let Some(path) = &self.pending_project_path {
|
||||
@@ -128,6 +151,39 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn tool_approval_panel<'a>(
|
||||
&'a self,
|
||||
prompt: &'a crate::agent::ApprovalPrompt,
|
||||
) -> Element<'a, Message> {
|
||||
let dialog = container(
|
||||
column![
|
||||
text(&prompt.title).size(22),
|
||||
text(&prompt.detail).size(13),
|
||||
text("Working directory").size(11).color(muted_text()),
|
||||
text(prompt.working_directory.display().to_string()).size(13),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
action_button("Deny").on_press(Message::DenyTool),
|
||||
action_button("Allow once").on_press(Message::AllowToolOnce),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(22)
|
||||
.width(560)
|
||||
.style(overview_style);
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn sidebar(&self) -> Element<'_, Message> {
|
||||
let preference_content = row![
|
||||
icon(ICON_SETTINGS, 17),
|
||||
|
||||
@@ -75,6 +75,17 @@ impl App {
|
||||
if message.system {
|
||||
continue;
|
||||
}
|
||||
if message.tool
|
||||
&& index > 0
|
||||
&& !crate::agent::stored_tool_cards(
|
||||
self.config.model,
|
||||
&self.conversation[index - 1].content,
|
||||
None,
|
||||
)
|
||||
.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let label = if message.user {
|
||||
"You"
|
||||
} else if message.tool {
|
||||
@@ -135,11 +146,39 @@ impl App {
|
||||
text(self.activity.as_deref().unwrap_or("Loading model…")).size(14),
|
||||
);
|
||||
}
|
||||
if !message.user && !message.tool {
|
||||
for summary in
|
||||
crate::agent::tool_summaries(self.config.model, &message.content)
|
||||
if !message.user {
|
||||
let stored_result = self
|
||||
.conversation
|
||||
.get(index + 1)
|
||||
.filter(|message| message.tool)
|
||||
.map(|message| message.content.as_str());
|
||||
let cards = if stored_result.is_none() && {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
body = body.push(text(summary).size(13).color(muted_text()));
|
||||
!self.tool_cards.is_empty()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
} {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.tool_cards.clone()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
crate::agent::stored_tool_cards(
|
||||
self.config.model,
|
||||
&message.content,
|
||||
stored_result,
|
||||
)
|
||||
};
|
||||
if !cards.is_empty() {
|
||||
body = body.push(tool_cards(cards));
|
||||
}
|
||||
}
|
||||
let user = message.user;
|
||||
@@ -295,3 +334,80 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
|
||||
let mut rows = column![].spacing(0);
|
||||
for (index, card) in cards.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
rows = rows.push(horizontal_rule(1));
|
||||
}
|
||||
let parameters = crate::agent::tool_parameters(&card.call);
|
||||
let call = crate::agent::tool_call_text(&card.call);
|
||||
let copy_call = tooltip(
|
||||
action_button(text("Copy call").size(11))
|
||||
.padding([5, 9])
|
||||
.on_press(Message::CopyToolText(call)),
|
||||
container(text("Copy tool name and all arguments").size(11))
|
||||
.padding(8)
|
||||
.style(preference_group_style),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.gap(6);
|
||||
let copy_result = action_button(text("Copy result").size(11)).padding([5, 9]);
|
||||
let copy_result = if let Some(result) = &card.result {
|
||||
copy_result.on_press(Message::CopyToolText(result.clone()))
|
||||
} else {
|
||||
copy_result
|
||||
};
|
||||
let copy_result = tooltip(
|
||||
copy_result,
|
||||
container(text("Copy the complete tool result").size(11))
|
||||
.padding(8)
|
||||
.style(preference_group_style),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.gap(6);
|
||||
let mut actions = row![copy_call, copy_result]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center);
|
||||
if let Some(path) = card
|
||||
.result
|
||||
.as_deref()
|
||||
.and_then(crate::agent::tool_output_path)
|
||||
{
|
||||
actions = actions.push(
|
||||
tooltip(
|
||||
action_button(text("Open output").size(11))
|
||||
.padding([5, 9])
|
||||
.on_press(Message::OpenToolOutput(path)),
|
||||
container(text("Open the complete output file").size(11))
|
||||
.padding(8)
|
||||
.style(preference_group_style),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.gap(6),
|
||||
);
|
||||
}
|
||||
let mut content = column![
|
||||
row![
|
||||
text(card.call.name).size(13),
|
||||
Space::with_width(Length::Fill),
|
||||
text(card.state.label()).size(11).color(muted_text()),
|
||||
actions,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
text(parameters).size(12).color(muted_text()),
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(result) = card.result {
|
||||
let bounded = crate::agent::bounded_tool_text(&result, 1_200);
|
||||
content = content.push(text(bounded).size(12));
|
||||
}
|
||||
rows = rows.push(container(content).padding(10).width(Length::Fill));
|
||||
}
|
||||
container(rows)
|
||||
.width(Length::Fill)
|
||||
.style(preference_group_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user