Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ For work projects, you'll need a license:

Purchase at [getcmdr.com/pricing](https://getcmdr.com/pricing).

### OpenAI Build Week submission

For OpenAI Build Week, I worked with Codex to build a natural-language bulk-rename feature incl. renaming images based
on their content, and a UI for humans to review and apply the rename suggestions. I worked in one long, continuous
session, mainly using Terra (high) and some Sol (medium), from feature planning through
[PR #39](https://github.com/vdavid/cmdr/pull/39). I brought my existing codebase and guardrails, picked the scope, the
plan review process, the UX direction, and what to accept during manual QA. Codex turned those decisions into the
spec/pland then the impl. It delegated plan reviews, translations, and pieces of the implementation to subagents,
diagnosed failures from logs, wrote tests, then prepared the PR and docs. The bulk of the submission-window work is the
natural-language bulk rename feature, but it also needed an MCP extension, a tweak to add an OpenAI-compatible provider,
streaming-state, copyability, localization, and operation-safety changes in the PR, so it came out pretty complex. Mind
that Codex did not build the rest of Cmdr itself: the file manager, the built-in chat, image index, MCP infra, and ops
engine all existed before Build Week. The evidence and collaboration record are in
[docs/hackathon-submission.md](docs/hackathon-submission.md).

To run the submission without building from source, download Cmdr v0.35.0 from [getcmdr.com](https://getcmdr.com),
configure an AI provider, open a folder or select files, and ask Cmdr to rename them. Review the proposed rows, change
the Allow/Deny choices, and apply the accepted subset; use disposable files for evaluation. The exact submitted code,
scope, and validation record are in [PR #39](https://github.com/vdavid/cmdr/pull/39).

### Source code

The source becomes [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) after 3 years (rolling per release). Until
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ re-evaluate on the next rc and bump all three together or not at all.

`bindings.ts` is generated: change this behavior at the `builder()` call site and regenerate with
`pnpm bindings:regen`, never by hand-editing the output.

The Ask Cmdr bulk-rename review commands register in the same builder and type collector as every other typed command.
Their authority and filesystem behavior live with the agent and write-operation modules, not at this registration edge.
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/agent/DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Construction plan: [`docs/specs/ask-cmdr-plan.md`](../../../../../docs/specs/ask

## The agent can propose; only the user can approve

`RenameProposalStore` is managed with the agent runtime. It holds short-lived immutable rename proposals by opaque id;
the tool can stage one, but no agent path can approve or apply it.

**The invariant.** The agent can propose. Only the user can approve. Approval originates in the frontend as a user
action. There is no tool, and never will be a tool, that approves a proposal. Without that, `Propose` is `Write` with
extra steps.
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/src/agent/chat/DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ shows the soft-cap nudge — summarize-on-overflow is deferred (spec §3).
## Constants table (§10; initial values, tune with use)

- `MAX_TOOL_TURNS = 8` — per message; the loop stops before the 9th tool respond fires.
- `MAX_WALL_TIME = 60s` — per message wall-clock ceiling across the whole loop.
- `MAX_WALL_TIME = 120s` — per message wall-clock ceiling across the whole loop; leaves room for reasoning-heavy
OpenAI-compatible models while the tool-turn cap still prevents a runaway loop.
- `CONTEXT_TOKEN_BUDGET = 8_000` — target assembled-prompt size (spec's 6–10k band).
- `ELIDE_TOOL_RESULTS_AFTER_TURNS = 3` — tool results this many turns back (or more) elide.
- `THREAD_SOFT_CAP_MESSAGES = 40` — past this the UI nudges "start a fresh one?".
Expand Down Expand Up @@ -94,6 +95,9 @@ state is unambiguous:

### Model-change events

`ProposalReady` is a display-only stream event. The runtime emits it only after the proposal dispatcher staged
immutable rows in `RenameProposalStore`; chat history persists the concise tool result, not proposal authority.

A settings change can switch a thread's effective model mid-conversation; the thread logs
it honestly as a UI-facing event row (`store::ConversationEvent::ModelChanged`) so the
user sees which replies used which model. Two cooperating paths, one comparison
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/agent/chat/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub const MAX_TOOL_TURNS: usize = 8;

/// Per user message wall-clock ceiling across the whole tool loop. Initial value;
/// tune with use.
pub const MAX_WALL_TIME: Duration = Duration::from_secs(60);
pub const MAX_WALL_TIME: Duration = Duration::from_secs(120);

/// Target assembled-prompt size per call, in estimated tokens (spec's 6-10k band).
/// Assembly elides older tool results until it fits, never touching assistant prose.
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src-tauri/src/agent/chat/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ use crate::ignore_poison::IgnorePoison;

use super::context::{self, ContextEnvelope, MAX_TOOL_TURNS, MAX_WALL_TIME, PrefixInputs};
use super::system_prompt::SYSTEM_PROMPT;
use crate::agent::tools::propose::rename::RenameProposalSnapshot;

const LOG_TARGET: &str = "agent::chat";

Expand Down Expand Up @@ -85,6 +86,9 @@ pub enum AgentChatEvent {
/// A tool call finished dispatching. `ok` is false for a refusal or a handler
/// problem (inspected from OUR OWN typed result shape, not external wording).
ToolCallFinished { call_id: String, ok: bool },
/// A server-owned rename proposal is ready for the review surface. The
/// snapshot is display-only; later approval uses only opaque row ids.
ProposalReady { proposal: RenameProposalSnapshot },
/// The turn produced its final answer. Carries the persisted assistant message id.
Done {
message_id: i64,
Expand Down Expand Up @@ -155,7 +159,12 @@ fn emit(sink: &ChatEventSink, event: AgentChatEvent) {
/// read-only dispatch view ([`AppHandleDispatcher`]); tests inject a scripted double,
/// so [`run_turn`] needs no Tauri app.
pub trait ToolDispatcher: Send + Sync {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult>;
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome>;
}

pub struct ToolDispatchOutcome {
pub result: AgentToolResult,
pub proposal: Option<RenameProposalSnapshot>,
}

/// The production dispatcher: every call goes through `agent::tools::view::dispatch`,
Expand All @@ -171,8 +180,15 @@ impl<R: Runtime> AppHandleDispatcher<R> {
}

impl<R: Runtime> ToolDispatcher for AppHandleDispatcher<R> {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> {
async move { crate::agent::tools::view::dispatch(&self.app, call).await }.boxed()
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> {
async move {
let outcome = crate::agent::tools::view::dispatch(&self.app, call).await;
ToolDispatchOutcome {
result: outcome.result,
proposal: outcome.proposal,
}
}
.boxed()
}
}

Expand Down Expand Up @@ -431,7 +447,8 @@ pub async fn run_turn(
tool_turns += 1;
for part in &message.parts {
let AgentPart::ToolCall(call) = part else { continue };
let result = dispatcher.dispatch(call).await;
let dispatch = dispatcher.dispatch(call).await;
let result = dispatch.result;
emit(
sink,
AgentChatEvent::ToolCallFinished {
Expand All @@ -456,6 +473,9 @@ pub async fn run_turn(
) {
return persist_failed(sink, e);
}
if let Some(proposal) = dispatch.proposal {
emit(sink, AgentChatEvent::ProposalReady { proposal });
}
transcript.push(tool_message);
}
}
Expand Down
46 changes: 28 additions & 18 deletions apps/desktop/src-tauri/src/agent/chat/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,15 @@ fn program_to_deltas(program: Program) -> Vec<Result<AgentDelta, AgentLlmError>>
struct OkDispatcher;

impl ToolDispatcher for OkDispatcher {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> {
async move {
AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "looked_at": call.tool.as_wire_name() }),
elided: false,
ToolDispatchOutcome {
result: AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "looked_at": call.tool.as_wire_name() }),
elided: false,
},
proposal: None,
}
}
.boxed()
Expand All @@ -237,13 +240,16 @@ struct SleepingDispatcher {
}

impl ToolDispatcher for SleepingDispatcher {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> {
async move {
tokio::time::sleep(Duration::from_secs(self.secs)).await;
AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "ok": true }),
elided: false,
ToolDispatchOutcome {
result: AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "ok": true }),
elided: false,
},
proposal: None,
}
}
.boxed()
Expand All @@ -256,13 +262,16 @@ struct CancellingDispatcher {
}

impl ToolDispatcher for CancellingDispatcher {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> {
fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> {
async move {
self.token.cancel();
AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "ok": true }),
elided: false,
ToolDispatchOutcome {
result: AgentToolResult {
call_id: call.call_id.clone(),
content: json!({ "ok": true }),
elided: false,
},
proposal: None,
}
}
.boxed()
Expand Down Expand Up @@ -354,11 +363,12 @@ async fn max_wall_time_halts_the_loop() {
let llm = ProgrammableLlm::new(programs);
let (tx, _rx) = unbounded_channel();

// Each dispatch burns 61 virtual seconds, so the wall-time ceiling trips after one
// tool round.
// One dispatch crosses the configured wall-time ceiling, so it trips after one tool round.
let result = run_turn(
&llm,
&SleepingDispatcher { secs: 61 },
&SleepingDispatcher {
secs: MAX_WALL_TIME.as_secs() + 1,
},
&conn,
&[],
&params(id, Some("slow please")),
Expand Down
35 changes: 28 additions & 7 deletions apps/desktop/src-tauri/src/agent/chat/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ You help the user understand their files by looking at what Cmdr already knows:
drive index (sizes, listings, recency), the importance of folders, the operation log, \
and the live app state (panes, cursor, selection, volumes).

You are read-only. You can look and speak, never act. You have no tool that changes, \
moves, deletes, or renames anything, and no tool that reads the contents of a file. \
You can look and speak, and you can prepare a rename plan for the user to review. You never \
act: you have no tool that changes, moves, deletes, or renames anything, and no tool that reads the contents of a file. \
Only names, paths, and metadata reach you, never file contents. If the user asks you \
to change something or read a file's contents, tell them plainly that this version of \
Ask Cmdr can only look and answer, and point them at the app's own commands for the \
action.
to change something, explain that only they can approve a prepared rename plan, or point them at the app's own commands.

For a natural-language rename request, call list_pane_files first. It returns the focused \
selection when one exists, otherwise the focused folder, plus the exact volume ID for the plan. Treat \
that pane listing as ready to use: do not wait for a drive scan or image indexing to finish. The \
propose_rename_plan tool is always available. Use image_facts only when image contents would \
improve the names; if it is unavailable or incomplete, continue with names, dates, and other \
available metadata. If list_pane_files returns truncated: true, your reply must say you looked at only \
returned of total files, using those numbers, and must never imply you covered the full selection or folder. \
Preserve each file extension unless the user explicitly asks otherwise. Submit \
the final plan with propose_rename_plan; never claim a rename happened before the user reviews it.

Be honest about coverage. The tools tell you when their answer is partial: an index \
that is still scanning or stale, a size that is a lower bound, an unmounted or \
Expand All @@ -53,13 +61,16 @@ mod tests {

#[test]
fn prompt_states_the_read_only_self_description() {
assert!(SYSTEM_PROMPT.contains("read-only"), "must describe itself as read-only");
assert!(
SYSTEM_PROMPT.contains("prepare a rename plan"),
"must describe its proposal-only power"
);
assert!(
SYSTEM_PROMPT.contains("never file contents"),
"must state file contents never reach it (the privacy line)"
);
assert!(
SYSTEM_PROMPT.contains("look and speak, never act"),
SYSTEM_PROMPT.contains("never act"),
"must state it can look and speak but never act"
);
}
Expand All @@ -76,6 +87,16 @@ mod tests {
);
}

#[test]
fn prompt_requires_exact_pane_listing_truncation_disclosure() {
assert!(
SYSTEM_PROMPT.contains("truncated: true")
&& SYSTEM_PROMPT.contains("returned of total")
&& SYSTEM_PROMPT.contains("must never imply you covered the full"),
"a capped pane listing must be disclosed with exact returned and total counts"
);
}

#[test]
fn prompt_forbids_the_error_and_failed_words() {
// The prompt instructs the model to avoid "error"/"failed"; it may quote the
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/src/agent/llm/DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ typed "tool not available" result. The read-only guarantee is that `Unrecognized
in `agent_tool_view()`, so dispatch refuses it — a typed view-membership check, not a string match. The known variants
are the read-only families (`AppState`, `ListDir`, `LargestDirs`, `ImportantFolders`, `FolderImportance`, `ListVolumes`,
`OperationsList`, `OperationsGet`, `SearchPhotos`, `ImageFacts`), pinned 1:1 to `agent_tool_view()` by a structural test in `agent/tools`;
`ToolId::KNOWN` excludes `Unrecognized` by design.
`ToolId::KNOWN` excludes `Unrecognized` by design. `ProposeRenamePlan` is the one proposing variant: it stages a
reviewable plan and remains subject to the same typed-view gate, never an approval or write route.

## Tests

Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src-tauri/src/agent/llm/genai_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ use super::{AgentDeltaStream, AgentLlm};

const LOG_TARGET: &str = "agent::llm";

/// Per-call output room for reasoning-heavy OpenAI-compatible models. Reasoning tokens
/// share this budget with visible text and tool calls.
const AGENT_MAX_OUTPUT_TOKENS: u32 = 12_000;

/// The genai-backed agent LLM. Wraps a configured [`AiBackend`] (the interactive
/// model slot; the slot is resolved in the command layer) and reuses its adapter routing and
/// stream-cancel model.
Expand Down Expand Up @@ -112,6 +116,7 @@ fn build_request(system: &str, tools: &[ToolDeclaration], messages: &[AgentMessa
/// plus the per-provider reasoning posture.
fn build_options(adapter: AdapterKind) -> ChatOptions {
let options = ChatOptions::default()
.with_max_tokens(AGENT_MAX_OUTPUT_TOKENS)
.with_capture_content(true)
.with_capture_tool_calls(true)
.with_capture_usage(true)
Expand Down Expand Up @@ -563,6 +568,12 @@ mod tests {
);
}

#[test]
fn agent_calls_leave_room_for_reasoning_and_a_tool_call() {
let options = build_options(AdapterKind::OpenAI);
assert_eq!(options.max_tokens, Some(AGENT_MAX_OUTPUT_TOKENS));
}

#[test]
fn ai_error_maps_to_typed_agent_error() {
// Status-classified upstream (by HTTP status), mapped variant-to-variant
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src-tauri/src/agent/llm/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ pub enum ToolId {
/// One directory's immediate children plus its recursive size stats, from the
/// drive index.
ListDir,
/// Up to 200 entries from the focused pane's current backend listing cache,
/// scoped to the selection when present.
ListPaneFiles,
/// The largest subdirectories under a path, by recursive size (batches dir
/// stats and sorts).
LargestDirs,
Expand All @@ -169,6 +172,9 @@ pub enum ToolId {
/// What the media index stored for images the agent already has: the full recognized
/// text plus tags, per path (shared with the ai-client view). Text-only, no image bytes.
ImageFacts,
/// Stages a same-folder rename plan for the user to review. It never applies
/// a rename and has no approval path.
ProposeRenamePlan,
/// A tool name the agent does not recognize (hallucinated, a typo, or a
/// write/non-view tool). Carries the raw name for the transparent UI and the
/// typed "tool not available" result; always refused by dispatch.
Expand All @@ -179,9 +185,10 @@ impl ToolId {
/// Every known read-only variant, in wire order. Excludes [`ToolId::Unrecognized`]
/// by design (it's the refusal case, never a view entry). The 1:1 structural test
/// asserts these map exactly onto `agent_tool_view()`.
pub const KNOWN: [ToolId; 10] = [
pub const KNOWN: [ToolId; 12] = [
ToolId::AppState,
ToolId::ListDir,
ToolId::ListPaneFiles,
ToolId::LargestDirs,
ToolId::ImportantFolders,
ToolId::FolderImportance,
Expand All @@ -190,6 +197,7 @@ impl ToolId {
ToolId::OperationsGet,
ToolId::SearchPhotos,
ToolId::ImageFacts,
ToolId::ProposeRenamePlan,
];

/// The wire name for this tool: the genai `fn_name`, the DB token, and the IPC
Expand All @@ -198,6 +206,7 @@ impl ToolId {
match self {
ToolId::AppState => "app_state",
ToolId::ListDir => "list_dir",
ToolId::ListPaneFiles => "list_pane_files",
ToolId::LargestDirs => "largest_dirs",
ToolId::ImportantFolders => "important_folders",
ToolId::FolderImportance => "folder_importance",
Expand All @@ -206,6 +215,7 @@ impl ToolId {
ToolId::OperationsGet => "operations_get",
ToolId::SearchPhotos => "search_photos",
ToolId::ImageFacts => "image_facts",
ToolId::ProposeRenamePlan => "propose_rename_plan",
ToolId::Unrecognized(name) => name.as_str(),
}
}
Expand All @@ -218,6 +228,7 @@ impl ToolId {
match name {
"app_state" => ToolId::AppState,
"list_dir" => ToolId::ListDir,
"list_pane_files" => ToolId::ListPaneFiles,
"largest_dirs" => ToolId::LargestDirs,
"important_folders" => ToolId::ImportantFolders,
"folder_importance" => ToolId::FolderImportance,
Expand All @@ -226,6 +237,7 @@ impl ToolId {
"operations_get" => ToolId::OperationsGet,
"search_photos" => ToolId::SearchPhotos,
"image_facts" => ToolId::ImageFacts,
"propose_rename_plan" => ToolId::ProposeRenamePlan,
other => ToolId::Unrecognized(other.to_string()),
}
}
Expand Down
Loading