Skip to content

Commit 16f728a

Browse files
Add composer image attach and vision model payloads.
Users can attach PNG/JPEG/WebP in the desktop composer; the engine stores bounded artifact refs and hydrates Ollama images / OpenAI image_url parts only on the wire so text-only models still work and SQLite stays small. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d1d394d commit 16f728a

28 files changed

Lines changed: 615 additions & 25 deletions

‎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.

‎native/core/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ ratatui = { version = "=0.30.2", default-features = false, features = ["crosster
3838
crossterm = { version = "=0.29.0", default-features = false, features = ["event-stream", "bracketed-paste", "use-dev-tty"] }
3939
unicode-segmentation = "1"
4040
unicode-width = "0.2"
41+
base64 = "0.22"
4142

4243
[dev-dependencies]
4344
toml = "=0.9.12"

‎native/core/examples/probe_background.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ async fn main() -> Result<()> {
3232
let job = engine.start(StartRequest {
3333
workspace:project.clone(), session_id:None, model:None, mode:"code".into(), queue:false,
3434
task:"Start the existing server as a managed project background process named probe-server, with the exact command node server.mjs. Use background_start, then background_output to inspect its retained log until SERVER_READY appears. Report the actual marker following SERVER_READY and leave the server running. Do not use exec, change files or stop the server; the test harness will independently check its HTTP response and clean it up.".into()
35-
}).await?;
35+
36+
images: Vec::new(),
37+
}).await?;
3638
let mut approvals = 0;
3739
let finished = tokio::time::timeout(Duration::from_secs(300), async {
3840
let completion = engine.wait(&job.id);

‎native/core/examples/probe_sqlite.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ async fn main() -> Result<()> {
3131
let job = engine.start(StartRequest {
3232
workspace:project.clone(),session_id:None,model:None,mode:"review".into(),queue:false,
3333
task:"Inspect billing.db using the native SQLite table/query tools. Discover its schema, then use SQL SUM to calculate total_cents for paid invoices only. Report the actual integer as total_cents=NUMBER. Do not use a shell or change any data/files.".into()
34-
}).await?;
34+
35+
images: Vec::new(),
36+
}).await?;
3537
let finished = tokio::time::timeout(Duration::from_secs(240), engine.wait(&job.id)).await;
3638
engine.shutdown().await?;
3739
let finished = finished??;

‎native/core/examples/probe_task.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ async fn main() -> Result<()> {
7070
tokio::time::sleep(Duration::from_millis(50)).await;
7171
}
7272
});
73-
let request=StartRequest{workspace:project.clone(),task:"Fix the add function in src/lib.rs so it adds its two inputs. Inspect the file using tools, make the smallest change, then run exactly `cargo test --offline --lib` using exec with no cwd parameter. Preserve the existing tests and package files. Finish only after seeing the test result.".into(),session_id:None,model:None,mode:"code".into(),queue:false};
73+
let request=StartRequest{workspace:project.clone(),task:"Fix the add function in src/lib.rs so it adds its two inputs. Inspect the file using tools, make the smallest change, then run exactly `cargo test --offline --lib` using exec with no cwd parameter. Preserve the existing tests and package files. Finish only after seeing the test result.".into(),session_id:None,model:None,mode:"code".into(),queue:false
74+
images: Vec::new(),
75+
};
7476
let first = engine.start(request).await?;
7577
let completed =
7678
tokio::time::timeout(Duration::from_secs(240), engine.wait(&first.id)).await??;
@@ -107,7 +109,9 @@ async fn main() -> Result<()> {
107109
"Independent verification failed: {}",
108110
verified.stderr
109111
);
110-
let second=engine.start(StartRequest{workspace:project.clone(),task:"Read src/lib.rs again and tell me what add(-2, 1) now returns. Do not change files or run commands.".into(),session_id:Some(first.session_id.clone()),model:None,mode:"review".into(),queue:false}).await?;
112+
let second=engine.start(StartRequest{workspace:project.clone(),task:"Read src/lib.rs again and tell me what add(-2, 1) now returns. Do not change files or run commands.".into(),session_id:Some(first.session_id.clone()),model:None,mode:"review".into(),queue:false
113+
images: Vec::new(),
114+
}).await?;
111115
let continued =
112116
tokio::time::timeout(Duration::from_secs(180), engine.wait(&second.id)).await??;
113117
ensure!(

‎native/core/src/autonomy.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ pub fn runaway_action(repeats: usize) -> RunawayAction {
244244
}
245245

246246
pub fn capability_profile(provider: &str, context_limit: usize) -> CapabilityProfile {
247+
capability_profile_for(provider, "", context_limit)
248+
}
249+
250+
pub fn capability_profile_for(
251+
provider: &str,
252+
model: &str,
253+
context_limit: usize,
254+
) -> CapabilityProfile {
247255
let local = matches!(
248256
provider,
249257
"ollama" | "local" | "llamacpp" | "lmstudio" | "vllm" | "mock"
@@ -254,7 +262,8 @@ pub fn capability_profile(provider: &str, context_limit: usize) -> CapabilityPro
254262
tools: provider != "mock",
255263
structured_output: !local,
256264
reasoning: matches!(provider, "openai" | "openrouter" | "anthropic"),
257-
multimodal: matches!(provider, "openai" | "openrouter" | "ollama"),
265+
multimodal: crate::vision::model_supports_vision(provider, model)
266+
|| (model.is_empty() && matches!(provider, "openai" | "openrouter")),
258267
streaming: true,
259268
parallel_tools: !matches!(provider, "ollama" | "llamacpp"),
260269
quirks: match provider {

‎native/core/src/context.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ pub fn repair_incomplete(messages: &mut Vec<Value>) {
7676
}
7777

7878
pub fn estimate_tokens(value: &Value) -> usize {
79+
if let Some(arr) = value.as_array() {
80+
return arr.iter().map(estimate_tokens).sum();
81+
}
82+
if value.get("role").is_some() {
83+
return crate::vision::estimate_message_tokens(value);
84+
}
7985
value.to_string().len().div_ceil(3)
8086
}
8187

‎native/core/src/engine.rs‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ pub struct StartRequest {
5959
pub mode: String,
6060
#[serde(default)]
6161
pub queue: bool,
62+
/// Workspace-relative image attachment paths (png/jpeg/webp).
63+
#[serde(default)]
64+
pub images: Vec<String>,
6265
}
6366
fn default_mode() -> String {
6467
"code".into()
@@ -71,6 +74,8 @@ pub struct Job {
7174
pub session_id: String,
7275
pub task_id: String,
7376
pub task: String,
77+
#[serde(default)]
78+
pub images: Vec<String>,
7479
pub status: String,
7580
pub mode: String,
7681
pub model: String,
@@ -328,8 +333,14 @@ impl Engine {
328333
"Application is shutting down"
329334
);
330335
ensure!(
331-
!request.task.trim().is_empty() && request.task.len() <= 128_000,
332-
"Task must contain between 1 and 128000 bytes"
336+
(!request.task.trim().is_empty() || !request.images.is_empty())
337+
&& request.task.len() <= 128_000,
338+
"Task must contain between 1 and 128000 bytes, or include an image attachment"
339+
);
340+
ensure!(
341+
request.images.len() <= crate::vision::MAX_IMAGES_PER_TURN,
342+
"At most {} images may be attached per task",
343+
crate::vision::MAX_IMAGES_PER_TURN
333344
);
334345
ensure!(
335346
matches!(request.mode.as_str(), "code" | "plan" | "review")
@@ -417,6 +428,7 @@ impl Engine {
417428
session_id: sid,
418429
task_id: crate::id(),
419430
task: request.task,
431+
images: request.images,
420432
status: "queued".into(),
421433
mode: request.mode,
422434
model: if context.command.is_some() {
@@ -881,9 +893,15 @@ impl Engine {
881893
system.push_str(extra);
882894
}
883895
messages.insert(0, json!({"role":"system","content":system}));
884-
messages.push(json!({"role":"user","content":job.task}));
896+
let image_refs = crate::vision::refs_from_paths(&running.workspace, &job.images)?;
897+
crate::vision::ensure_vision_or_bail(
898+
&running.config.model.provider,
899+
&running.config.model.name,
900+
image_refs.len(),
901+
)?;
902+
messages.push(crate::vision::user_message(&job.task, &image_refs));
885903
self.0.store.save_messages(&job.id, &messages)?;
886-
events.emit("agent.started",json!({"job_id":job.id,"task":job.task,"mode":job.mode,"model":job.model,"native":true}))?;
904+
events.emit("agent.started",json!({"job_id":job.id,"task":job.task,"mode":job.mode,"model":job.model,"native":true,"images":job.images}))?;
887905
if let Some(decision) = &job.routing {
888906
events.emit(
889907
if decision.fallback_reason.is_some() {
@@ -965,7 +983,15 @@ impl Engine {
965983
let mut flushed = Instant::now();
966984
let mut event_error = None;
967985
let response = model
968-
.chat(&messages, &schemas, running.cancel.clone(), |delta| {
986+
.chat(
987+
&crate::vision::hydrate_for_provider(
988+
&messages,
989+
&running.workspace,
990+
&running.config.model.provider,
991+
)?,
992+
&schemas,
993+
running.cancel.clone(),
994+
|delta| {
969995
partial.push_str(delta);
970996
pending.push_str(delta);
971997
if pending.len() >= 4000 || flushed.elapsed() >= Duration::from_millis(80) {

‎native/core/src/engine/goals.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ impl Engine {
174174
model: None,
175175
mode: milestone["mode"].as_str().unwrap_or("code").into(),
176176
queue: true,
177+
images: Vec::new(),
177178
},
178179
LaunchContext {
179180
system_context: Some(context),

‎native/core/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod service;
3232
pub mod sqlite;
3333
pub mod store;
3434
pub mod tools;
35+
pub mod vision;
3536
pub mod workflows;
3637
pub mod workspace;
3738
pub mod worktrees;

0 commit comments

Comments
 (0)