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
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2538,6 +2538,7 @@ pub async fn read_file_content(
read_text_file(
&state,
&request.file_path,
request.encoding.as_deref(),
request.remote_connection_id.as_deref(),
)
.await
Expand Down
55 changes: 48 additions & 7 deletions src/apps/desktop/src/api/path_target.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Shared desktop resolution and access helpers for local, runtime, and remote paths.

use crate::api::app_state::AppState;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use bitfun_core::agentic::tools::workspace_paths::{
is_bitfun_runtime_uri, parse_bitfun_runtime_uri,
};
Expand Down Expand Up @@ -241,17 +242,26 @@ pub fn stat_local_path_metadata(
pub async fn read_text_file(
app_state: &AppState,
raw_path: &str,
encoding: Option<&str>,
preferred_remote_connection_id: Option<&str>,
) -> Result<String, String> {
let target =
resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?;
match &target {
DesktopPathTarget::Local { resolved_path, .. } => app_state
.filesystem_service
.read_file(&resolved_path.to_string_lossy())
.await
.map(|result| result.content)
.map_err(|e| format!("Failed to read file content: {}", e)),
DesktopPathTarget::Local { resolved_path, .. } => {
let result = app_state
.filesystem_service
.read_file(&resolved_path.to_string_lossy())
.await
.map_err(|e| format!("Failed to read file content: {}", e))?;
if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
&& !result.encoding.eq_ignore_ascii_case("base64")
{
Ok(BASE64.encode(result.content.as_bytes()))
} else {
Ok(result.content)
}
}
DesktopPathTarget::Remote {
requested_path,
entry,
Expand All @@ -264,11 +274,42 @@ pub async fn read_text_file(
.read_file(&entry.connection_id, requested_path)
.await
.map_err(|e| format!("Failed to read remote file: {}", e))?;
String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))
encode_remote_file_bytes(bytes, encoding)
}
}
}

fn encode_remote_file_bytes(bytes: Vec<u8>, encoding: Option<&str>) -> Result<String, String> {
if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64")) {
return Ok(BASE64.encode(bytes));
}

String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))
}

#[cfg(test)]
mod tests {
use super::encode_remote_file_bytes;

#[test]
fn remote_file_bytes_support_explicit_base64_encoding() {
let png_header = vec![0x89, b'P', b'N', b'G'];
assert_eq!(
encode_remote_file_bytes(png_header, Some("base64")).expect("base64 should encode"),
"iVBORw=="
);
}

#[test]
fn remote_file_bytes_preserve_text_default() {
assert_eq!(
encode_remote_file_bytes(b"hello".to_vec(), None).expect("text should decode"),
"hello"
);
assert!(encode_remote_file_bytes(vec![0xff], None).is_err());
}
}

pub async fn write_text_file(
app_state: &AppState,
raw_path: &str,
Expand Down
1 change: 1 addition & 0 deletions src/apps/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tokio = { workspace = true, features = ["full"] }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
futures-util = { workspace = true }
Expand Down
11 changes: 10 additions & 1 deletion src/apps/server/src/rpc_dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use crate::bootstrap::ServerAppState;
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use bitfun_core::agentic::agents::SubAgentSource;
use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource};
use bitfun_core::agentic::core::SessionConfig;
Expand Down Expand Up @@ -95,12 +96,20 @@ pub async fn dispatch(
"read_file_content" => {
let request = extract_request(&params)?;
let file_path = get_string(&request, "filePath")?;
let encoding = request.get("encoding").and_then(|value| value.as_str());
let result = state
.filesystem_service
.read_file(&file_path)
.await
.map_err(|e| anyhow!("{}", e))?;
Ok(serde_json::json!(result.content))
let content = if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
&& !result.encoding.eq_ignore_ascii_case("base64")
{
BASE64.encode(result.content.as_bytes())
} else {
result.content
};
Ok(serde_json::json!(content))
}
"write_file_content" => {
let request = extract_request(&params)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub struct PromptBuilderContext {
pub runtime_context_needs: RuntimeContextNeeds,
/// Remote mobile/bot turns need `computer://` links for file delivery.
pub remote_file_delivery_channel: bool,
/// The active response surface can render Markdown image syntax inline.
pub inline_markdown_image_display: bool,
}

impl PromptBuilderContext {
Expand All @@ -73,6 +75,7 @@ impl PromptBuilderContext {
tool_listing_sections: ToolListingSections::default(),
runtime_context_needs: RuntimeContextNeeds::default(),
remote_file_delivery_channel: false,
inline_markdown_image_display: false,
}
}

Expand Down Expand Up @@ -110,6 +113,11 @@ impl PromptBuilderContext {
self.remote_file_delivery_channel = enabled;
self
}

pub fn with_inline_markdown_image_display(mut self, enabled: bool) -> Self {
self.inline_markdown_image_display = enabled;
self
}
}

pub async fn build_prompt_context_for_workspace(
Expand Down Expand Up @@ -244,6 +252,7 @@ impl PromptBuilder {
remote_execution: self.context.remote_execution.clone(),
local_shell,
supports_image_understanding: self.context.supports_image_understanding,
inline_markdown_image_display: self.context.inline_markdown_image_display,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ use crate::service::workspace::{
};
use crate::service_agent_runtime::CoreServiceAgentRuntime;
use crate::util::errors::{BitFunError, BitFunResult};
use bitfun_agent_runtime::output_surface::{
supports_inline_markdown_images_for_source, TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY,
};
use bitfun_agent_runtime::remote_file_delivery::{
needs_computer_links_for_source, remote_file_delivery_reminder,
TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY,
Expand Down Expand Up @@ -3546,6 +3549,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
"true".to_string(),
);
}
if supports_inline_markdown_images_for_source(submission_policy.trigger_source) {
context_vars.insert(
TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY.to_string(),
"true".to_string(),
);
}
let session_workspace_path = session_workspace
.as_ref()
.map(|workspace| workspace.root_path_string());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ use crate::util::token_counter::TokenCounter;
use crate::util::types::Message as AIMessage;
use crate::util::types::ToolDefinition;
use crate::util::{elapsed_ms_u64, truncate_at_char_boundary};
use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY;
use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY;
use bitfun_ai_adapters::ModelExchangeTraceConfig;
use log::{debug, error, info, trace, warn};
Expand Down Expand Up @@ -971,6 +972,11 @@ impl ExecutionEngine {
.get(TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY)
.and_then(|value| value.parse::<bool>().ok())
.unwrap_or(false);
let inline_markdown_image_display = context
.context
.get(TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY)
.and_then(|value| value.parse::<bool>().ok())
.unwrap_or(false);

build_prompt_context_for_workspace(
workspace,
Expand All @@ -983,7 +989,9 @@ impl ExecutionEngine {
)
.await
.map(|prompt_context| {
prompt_context.with_remote_file_delivery_channel(remote_file_delivery_channel)
prompt_context
.with_remote_file_delivery_channel(remote_file_delivery_channel)
.with_inline_markdown_image_display(inline_markdown_image_display)
})
}

Expand Down
1 change: 1 addition & 0 deletions src/crates/execution/agent-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod event_router;
pub mod events;
pub mod evidence_ledger;
pub mod file_read_state;
pub mod output_surface;
pub mod post_call_hooks;
pub mod prompt;
pub mod prompt_cache;
Expand Down
31 changes: 31 additions & 0 deletions src/crates/execution/agent-runtime/src/output_surface.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use bitfun_runtime_ports::DialogTriggerSource;

pub const TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY: &str = "inline_markdown_image_display";

pub const fn supports_inline_markdown_images_for_source(source: DialogTriggerSource) -> bool {
matches!(source, DialogTriggerSource::DesktopUi)
}

#[cfg(test)]
mod tests {
use super::supports_inline_markdown_images_for_source;
use bitfun_runtime_ports::DialogTriggerSource;

#[test]
fn inline_markdown_images_are_scoped_to_desktop_ui() {
assert!(supports_inline_markdown_images_for_source(
DialogTriggerSource::DesktopUi
));

for source in [
DialogTriggerSource::DesktopApi,
DialogTriggerSource::AgentSession,
DialogTriggerSource::ScheduledJob,
DialogTriggerSource::RemoteRelay,
DialogTriggerSource::Bot,
DialogTriggerSource::Cli,
] {
assert!(!supports_inline_markdown_images_for_source(source));
}
}
}
18 changes: 17 additions & 1 deletion src/crates/execution/agent-runtime/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,31 @@ pub struct RuntimeContextFacts {
pub remote_execution: Option<RemoteExecutionHints>,
pub local_shell: Option<RuntimeShellFacts>,
pub supports_image_understanding: Option<bool>,
pub inline_markdown_image_display: bool,
}

pub fn render_runtime_context_reminder(facts: &RuntimeContextFacts) -> Option<String> {
if facts.needs.is_empty() {
if facts.needs.is_empty() && !facts.inline_markdown_image_display {
return None;
}

let mut lines = vec!["# Runtime Context".to_string()];

if facts.inline_markdown_image_display {
push_runtime_context_section(
&mut lines,
"Chat Image Display",
vec![
"- The current Desktop/Web chat renders Markdown images inline. To show an image to the user, use `![concise alt text](source)` in the response."
.to_string(),
"- Supported sources are verified HTTP(S) image URLs and workspace-relative image paths. Prefer PNG, JPEG, GIF, or WebP for reliable rendering."
.to_string(),
"- Do not invent image URLs, and do not call image-analysis tools solely to display an image. Use a URL you verified or a path to a file that exists in the active workspace."
.to_string(),
],
);
}

if facts.needs.workspace_tools {
let mut workspace_lines = Vec::new();
if let Some(remote) = &facts.remote_execution {
Expand Down
36 changes: 36 additions & 0 deletions src/crates/execution/agent-runtime/tests/prompt_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ fn runtime_context_renderer_preserves_local_exec_and_computer_use_guidance() {
invocation: "powershell.exe -NoLogo".to_string(),
}),
supports_image_understanding: None,
inline_markdown_image_display: false,
})
.expect("runtime context should render");

Expand Down Expand Up @@ -156,6 +157,7 @@ fn runtime_context_renderer_preserves_remote_workspace_split() {
invocation: "powershell.exe".to_string(),
}),
supports_image_understanding: None,
inline_markdown_image_display: false,
})
.expect("remote runtime context should render");

Expand All @@ -176,6 +178,7 @@ fn runtime_context_renderer_adds_text_only_computer_use_guidance_for_non_visual_
remote_execution: None,
local_shell: None,
supports_image_understanding: Some(false),
inline_markdown_image_display: false,
})
.expect("runtime context should render");

Expand All @@ -197,6 +200,7 @@ fn runtime_context_renderer_omits_text_only_guidance_for_visual_or_unknown_model
remote_execution: None,
local_shell: None,
supports_image_understanding,
inline_markdown_image_display: false,
})
.expect("runtime context should render");

Expand All @@ -206,6 +210,38 @@ fn runtime_context_renderer_omits_text_only_guidance_for_visual_or_unknown_model
}
}

#[test]
fn runtime_context_renderer_scopes_inline_image_guidance_to_capable_surfaces() {
let reminder = render_runtime_context_reminder(&RuntimeContextFacts {
needs: RuntimeContextNeeds::default(),
host_os: "linux".to_string(),
host_family: "unix".to_string(),
host_arch: "x86_64".to_string(),
remote_execution: None,
local_shell: None,
supports_image_understanding: None,
inline_markdown_image_display: true,
})
.expect("output-surface context should render without tool runtime facts");

assert!(reminder.contains("## Chat Image Display"));
assert!(reminder.contains("`![concise alt text](source)`"));
assert!(reminder.contains("workspace-relative image paths"));
assert!(reminder.contains("do not call image-analysis tools solely to display an image"));

assert!(render_runtime_context_reminder(&RuntimeContextFacts {
needs: RuntimeContextNeeds::default(),
host_os: "linux".to_string(),
host_family: "unix".to_string(),
host_arch: "x86_64".to_string(),
remote_execution: None,
local_shell: None,
supports_image_understanding: None,
inline_markdown_image_display: false,
})
.is_none());
}

#[test]
fn workspace_and_user_context_renderers_preserve_section_shape() {
let local = render_workspace_context(&WorkspaceContextFacts {
Expand Down
Loading
Loading