Skip to content
Open
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
15 changes: 10 additions & 5 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,9 +944,11 @@ impl Channel {
"/quiet" | "/observe" => {
self.set_response_mode(ResponseMode::Observe).await;
self.send_builtin_text(
"observe mode enabled. i'll learn from this conversation but won't respond.".to_string(),
"observe mode enabled. i'll learn from this conversation but won't respond."
.to_string(),
"observe",
).await;
)
.await;
return Ok(true);
}
"/active" => {
Expand Down Expand Up @@ -976,7 +978,8 @@ impl Channel {
"- /tasks: ready task list".to_string(),
"- /digest: one-shot day digest (00:00 -> now)".to_string(),
"- /observe: learn from conversation, never respond".to_string(),
"- /mention-only: only respond when @mentioned, replied to, or given a command".to_string(),
"- /mention-only: only respond when @mentioned, replied to, or given a command"
.to_string(),
"- /active: normal reply mode".to_string(),
"- /agent-id: runtime agent id".to_string(),
];
Expand Down Expand Up @@ -3004,7 +3007,8 @@ impl Channel {

// Truncate for working memory — full conclusion lives in branch_runs.
let summary = if conclusion.len() > 200 {
format!("{}...", &conclusion[..200])
let end = conclusion.floor_char_boundary(200);
format!("{}...", &conclusion[..end])
} else {
conclusion.clone()
};
Expand Down Expand Up @@ -3077,7 +3081,8 @@ impl Channel {

// Record worker completion in working memory.
let worker_summary = if result.len() > 200 {
format!("{}...", &result[..200])
let end = result.floor_char_boundary(200);
format!("{}...", &result[..end])
} else {
result.clone()
};
Expand Down
4 changes: 3 additions & 1 deletion src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ fn parse_response_mode(
// Backwards compat: listen_only_mode maps to response_mode
match listen_only_mode {
Some(true) => {
tracing::warn!("listen_only_mode is deprecated, use response_mode = \"observe\" instead");
tracing::warn!(
"listen_only_mode is deprecated, use response_mode = \"observe\" instead"
);
Some(ResponseMode::Observe)
}
Some(false) => Some(ResponseMode::Active),
Expand Down
34 changes: 25 additions & 9 deletions src/llm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2348,26 +2348,40 @@ fn parse_streamed_tool_arguments(
return Ok(serde_json::json!({}));
}

let direct_parse_error = match serde_json::from_str::<serde_json::Value>(raw_arguments) {
Ok(arguments) => return Ok(arguments),
Err(error) => error,
let mut iter =
serde_json::Deserializer::from_str(raw_arguments).into_iter::<serde_json::Value>();
let direct_parse_error = match iter.next() {
Some(Ok(arguments)) => return Ok(arguments),
Some(Err(error)) => error,
None => {
return Err(CompletionError::ProviderError(format!(
"invalid streamed tool arguments for '{tool_name}': empty JSON stream"
)));
}
};

let sanitized_arguments = escape_control_characters_in_json_strings(raw_arguments);
if sanitized_arguments != raw_arguments {
match serde_json::from_str::<serde_json::Value>(&sanitized_arguments) {
Ok(arguments) => {
let mut sanitized_iter = serde_json::Deserializer::from_str(&sanitized_arguments)
.into_iter::<serde_json::Value>();
match sanitized_iter.next() {
Some(Ok(arguments)) => {
tracing::warn!(
tool_name,
"normalized control characters in streamed tool arguments"
);
return Ok(arguments);
}
Err(sanitized_parse_error) => {
Some(Err(sanitized_parse_error)) => {
return Err(CompletionError::ProviderError(format!(
"invalid streamed tool arguments for '{tool_name}': {direct_parse_error}; after sanitization: {sanitized_parse_error}"
)));
}
None => {
return Err(CompletionError::ProviderError(format!(
"invalid streamed tool arguments for '{tool_name}': {direct_parse_error}; after sanitization: empty JSON stream"
)));
}
}
}

Expand Down Expand Up @@ -3643,14 +3657,16 @@ fn provider_display_name(provider_id: &str) -> String {
}

fn remap_model_name_for_api(provider: &str, model_name: &str) -> String {
let stripped = model_name.strip_prefix("litellm/").unwrap_or(model_name);

if provider == "zai-coding-plan" {
// Coding Plan endpoint expects plain model ids (e.g. "glm-5").
model_name
stripped
.strip_prefix("zai/")
.unwrap_or(model_name)
.unwrap_or(stripped)
.to_string()
} else {
model_name.to_string()
stripped.to_string()
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/messaging/signal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,8 @@ impl Messaging for SignalAdapter {
// Log response body at debug level only (may contain sensitive data)
if let Ok(body_text) = response.text().await {
let truncated = if body_text.len() > 200 {
format!("{}...<truncated>", &body_text[..200])
let end = body_text.floor_char_boundary(200);
format!("{}...<truncated>", &body_text[..end])
} else {
body_text
};
Expand Down
8 changes: 4 additions & 4 deletions src/messaging/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,10 +666,10 @@ pub fn is_valid_instance_name(name: &str) -> bool {
if name.chars().all(|c| c.is_ascii_digit()) {
return false;
}
// Must not look like a Slack workspace ID (Txxxxx, Cxxxxx, etc.)
if name.len() > 6
&& name.starts_with(|c: char| c.is_ascii_uppercase())
&& name[1..].chars().all(|c| c.is_ascii_digit())
// Must not look like a Slack workspace ID (Txxxxx, Cxxxxx, Exxxxx, etc.)
if name.len() >= 6
&& (name.starts_with('T') || name.starts_with('C') || name.starts_with('E'))
&& name[1..].chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion src/projects/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ pub async fn remove_worktree(repo_path: &Path, worktree_path: &Path) -> anyhow::
.context("worktree path is not valid UTF-8")?;

let output = Command::new("git")
.args(["worktree", "remove", worktree_str])
.args(["worktree", "remove", "--force", worktree_str])
.current_dir(repo_path)
.output()
.await
Expand Down
10 changes: 6 additions & 4 deletions src/tools/project_manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,8 @@ impl ProjectManageTool {
let repo_abs_path = root.join(&repo.path);
let is_single_repo = repo.path == ".";

// For single-repo projects, place worktrees in the parent directory
// (as siblings of the repo). For multi-repo projects, place them
// inside the project root.
// For multi-repo projects, place them inside the project root,
// prefixed with `.worktrees/repo_name-worktree_name` to avoid conflicts.
let (worktree_abs_path, worktree_db_path) = if is_single_repo {
let parent = root.parent().ok_or_else(|| {
ProjectManageError("single-repo project root has no parent directory".into())
Expand All @@ -652,7 +651,10 @@ impl ProjectManageTool {
format!("../{worktree_dir_name}"),
)
} else {
(root.join(&worktree_dir_name), worktree_dir_name.clone())
// Include repo name in the worktree directory name to avoid conflicts
// with other repos or their worktrees in a multi-repo project.
let dir_name = format!("{}-{}", repo.name, worktree_dir_name);
(root.join(&dir_name), dir_name)
};

// Create the git worktree (branch from HEAD of the repo)
Expand Down