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
46 changes: 30 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
🚧Early-stage project under active development — not production-ready yet.
⭐ Star us to follow

[![Status](https://img.shields.io/badge/status-designing-blue?style=flat-square)](https://github.com/)
[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers)
[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Origin](https://img.shields.io/badge/origin-Claude_Code_TS-8A2BE2?style=flat-square)](https://docs.anthropic.com/en/docs/claude-code)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls)
[![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions)
[![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases)


[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [Español](./README.es.md) | [Français](./README.fr.md) | [Português do Brasil](./README.pt-BR.md) | [Deutsch](./README.de.md) | [Русский](./README.ru.md) | [Türkçe](./README.tr.md)

Expand Down Expand Up @@ -52,7 +54,7 @@ irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex
> [!TIP]
> `devo` can check for newer GitHub releases on startup and print the matching
> upgrade command. You can disable or tune this with the `[updates]` section in
> `DEVO_HOME/config.toml` or `<workspace>/.devo/config.toml`.
> `DEVO_HOME/config.toml` (defaults to `~/.devo/config.toml` on macOS/linux, `C:\Users\yourname\.devo\config.toml` on Windows.) or `<workspace>/.devo/config.toml`.

## 🚀 Quick Start

Expand Down Expand Up @@ -80,7 +82,7 @@ Devo reads configuration from a TOML file, merged with higher-priority sources
overriding lower-priority ones:

1. Built-in defaults (compiled into the binary)
2. `DEVO_HOME/config.toml` — user-level config (defaults to `~/.devo/config.toml`)
2. `DEVO_HOME/config.toml` — user-level config (defaults to `~/.devo/config.toml` on macOS/linux, `C:\Users\yourname\.devo\config.toml` on Windows.)
3. `<workspace>/.devo/config.toml` — project-level config
4. CLI flags — command-line overrides

Expand Down Expand Up @@ -109,7 +111,8 @@ model = "deepseek-v4-pro"
model = "deepseek-v4-flash"
```

### Full Config Reference
<details>
<summary>Full Config Reference (click to expand)</summary>

```toml
# ── Model Provider (required) ───────────────────────────────────
Expand Down Expand Up @@ -155,7 +158,7 @@ persist_ephemeral_sessions = false # optional

[logging]
level = "info" # optional, trace, debug, info, warn, error
json = false # optional, emit JSON-formatted logs
json = false # optional, emit JSON-formatted logs
redact_secrets_in_logs = true # optional

[logging.file]
Expand All @@ -165,18 +168,18 @@ rotation = "Daily" # optional, Never | Minutely | Hourly | Daily
max_files = 14 # optional

[skills]
enabled = true # optional
user_roots = ["skills"] # optional, dirs to scan for user skills
workspace_roots = ["skills"] # optional, dirs to scan for workspace skills
watch_for_changes = true # optional
enabled = true # optional
user_roots = ["skills"] # optional, dirs to scan for user skills
workspace_roots = ["skills"] # optional, dirs to scan for workspace skills
watch_for_changes = true # optional

[updates]
enabled = true # optional
check_on_startup = true # optional
check_interval_hours = 24 # optional
```

enabled = true # optional
check_on_startup = true # optional
check_interval_hours = 24 # optional
### Model Catalog (`~/.devo/models.json`)
```
</details>

A separate JSON file defines available models and their capabilities. On first
run, the built-in catalog is automatically copied to `~/.devo/models.json` so
Expand Down Expand Up @@ -214,6 +217,17 @@ with credentials in `config.toml`, not the full catalog.
|---------------|-----------------------------------------------|
| `DEVO_HOME` | Override the config directory (default: `~/.devo`) |


## Star us

<a href="https://www.star-history.com/?repos=7df-lab%2Fdevo&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left" />
</picture>
</a>

## FAQ

### How is this different from Claude Code?
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/conversation/records.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ pub struct ToolResultItem {
pub tool_name: Option<String>,
/// The normalized structured output returned by the tool.
pub output: serde_json::Value,
/// Optional UI-only text for displaying the result without changing replay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_content: Option<String>,
/// Whether the result represents an error outcome.
pub is_error: bool,
}
Expand Down
191 changes: 188 additions & 3 deletions crates/core/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pub enum QueryEvent {
ToolResult {
tool_use_id: String,
content: String,
display_content: Option<String>,
is_error: bool,
/// Human-readable summary for client-side rendering (e.g. "bash: npm run dev").
summary: String,
Expand Down Expand Up @@ -807,8 +808,21 @@ pub async fn query(
return Ok(());
}

// Execute tool calls
let results = runtime.execute_batch(&tool_calls).await;
// Execute tool calls. When a caller is observing query events, wire
// tool progress into the same event stream so long-running commands can
// render live output before the final ToolResult arrives.
let results = if let Some(progress_events) = on_event.clone() {
runtime
.execute_batch_streaming(&tool_calls, move |tool_use_id, content| {
progress_events(QueryEvent::ToolProgress {
tool_use_id: tool_use_id.to_string(),
content: content.to_string(),
});
})
.await
} else {
runtime.execute_batch(&tool_calls).await
};

// Build tool call name -> input map for computing summaries
let tool_call_map: std::collections::HashMap<&str, (&str, &serde_json::Value)> = tool_calls
Expand All @@ -823,6 +837,7 @@ pub async fn query(
.map(|r| {
let content_str = r.content.into_string();
let compacted_content = micro_compact(content_str);
let compacted_display_content = r.display_content.map(micro_compact);
let summary = tool_call_map
.get(r.tool_use_id.as_str())
.map(|(name, input)| {
Expand All @@ -832,6 +847,7 @@ pub async fn query(
emit(QueryEvent::ToolResult {
tool_use_id: r.tool_use_id.clone(),
content: compacted_content.clone(),
display_content: compacted_display_content,
is_error: r.is_error,
summary: summary.clone(),
});
Expand Down Expand Up @@ -1183,7 +1199,6 @@ mod tests {
}
}

#[async_trait]
#[async_trait]
impl ToolHandler for MutatingTool {
fn tool_kind(&self) -> ToolHandlerKind {
Expand All @@ -1199,6 +1214,45 @@ mod tests {
}
}

struct DisplayContentTool;

#[async_trait]
impl ToolHandler for DisplayContentTool {
fn tool_kind(&self) -> ToolHandlerKind {
ToolHandlerKind::Read
}

async fn handle(
&self,
_invocation: ToolInvocation,
_progress: Option<devo_tools::events::ToolProgressSender>,
) -> Result<Box<dyn ToolOutput>, ToolExecutionError> {
Ok(Box::new(
FunctionToolOutput::success("canonical").with_display_content("display"),
))
}
}

struct StreamingMutatingTool;

#[async_trait]
impl ToolHandler for StreamingMutatingTool {
fn tool_kind(&self) -> ToolHandlerKind {
ToolHandlerKind::Write
}

async fn handle(
&self,
_invocation: ToolInvocation,
progress: Option<devo_tools::events::ToolProgressSender>,
) -> Result<Box<dyn ToolOutput>, ToolExecutionError> {
if let Some(sender) = progress {
let _ = sender.send("stream chunk\n".to_string());
}
Ok(Box::new(FunctionToolOutput::success("stream complete")))
}
}

#[tokio::test]
async fn query_retries_transient_stream_creation_errors() {
let provider = Arc::new(TransientStreamCreateProvider {
Expand Down Expand Up @@ -1810,4 +1864,135 @@ mod tests {
assert!(!summary.is_empty(), "summary should not be empty");
}
}

#[tokio::test]
async fn query_emits_tool_result_display_content() {
let mut builder = ToolRegistryBuilder::new();
builder.register_handler("mutating_tool", Arc::new(DisplayContentTool));
builder.push_spec(ToolSpec {
name: "mutating_tool".into(),
description: String::new(),
input_schema: JsonSchema::object(Default::default(), None, None),
output_mode: ToolOutputMode::Text,
execution_mode: ToolExecutionMode::ReadOnly,
capability_tags: vec![],
supports_parallel: false,
});
let registry = Arc::new(builder.build());
let runtime = ToolRuntime::new_without_permissions(Arc::clone(&registry));

let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir());
session.push_message(Message::user("run the tool"));

let seen = Arc::new(Mutex::new(Vec::new()));
let seen_clone = Arc::clone(&seen);
let callback = Arc::new(move |event: QueryEvent| {
if let QueryEvent::ToolResult {
content,
display_content,
..
} = event
{
seen_clone.lock().unwrap().push((content, display_content));
}
});

query(
&mut session,
&TurnConfig {
model: Model::default(),
thinking_selection: None,
},
Arc::new(SingleToolUseProvider {
requests: AtomicUsize::new(0),
}),
registry,
&runtime,
Some(callback),
)
.await
.expect("query should complete");

assert_eq!(
seen.lock().unwrap().as_slice(),
&[(String::from("canonical"), Some(String::from("display")))]
);
}

#[tokio::test]
async fn query_emits_tool_progress_before_tool_result() {
let mut builder = ToolRegistryBuilder::new();
builder.register_handler("mutating_tool", Arc::new(StreamingMutatingTool));
builder.push_spec(ToolSpec {
name: "mutating_tool".into(),
description: String::new(),
input_schema: JsonSchema::object(Default::default(), None, None),
output_mode: ToolOutputMode::Text,
execution_mode: ToolExecutionMode::Mutating,
capability_tags: vec![],
supports_parallel: false,
});
let registry = Arc::new(builder.build());
let runtime = ToolRuntime::new_without_permissions(Arc::clone(&registry));

let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir());
session.push_message(Message::user("run the tool"));

let seen = Arc::new(Mutex::new(Vec::new()));
let seen_clone = Arc::clone(&seen);
let callback = Arc::new(move |event: QueryEvent| {
seen_clone.lock().unwrap().push(event);
});

query(
&mut session,
&TurnConfig {
model: Model::default(),
thinking_selection: None,
},
Arc::new(SingleToolUseProvider {
requests: AtomicUsize::new(0),
}),
registry,
&runtime,
Some(callback),
)
.await
.expect("query should complete");

let events = seen.lock().unwrap();
let progress_index = events
.iter()
.position(|event| {
matches!(
event,
QueryEvent::ToolProgress {
tool_use_id,
content,
} if tool_use_id == "tool-1" && content == "stream chunk\n"
)
})
.expect("tool progress event should be emitted");
let result_index = events
.iter()
.position(|event| {
matches!(
event,
QueryEvent::ToolResult {
tool_use_id,
content,
is_error,
..
} if tool_use_id == "tool-1"
&& content == "stream complete"
&& !is_error
)
})
.expect("tool result event should be emitted");

assert!(
progress_index < result_index,
"tool progress should arrive before final result"
);
}
}
31 changes: 31 additions & 0 deletions crates/protocol/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub struct ToolResultPayload {
pub tool_call_id: String,
pub tool_name: Option<String>,
pub content: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_content: Option<String>,
pub is_error: bool,
#[serde(default)]
pub summary: String,
Expand Down Expand Up @@ -322,6 +324,35 @@ mod tests {
assert_eq!(restored.pending_texts, vec!["first", "second"]);
}

#[test]
fn tool_result_payload_display_content_is_optional() {
let payload: ToolResultPayload = serde_json::from_str(
r#"{
"tool_call_id": "call-1",
"tool_name": "read",
"content": "canonical",
"is_error": false
}"#,
)
.expect("deserialize legacy payload");
assert_eq!(payload.display_content, None);
assert_eq!(payload.summary, "");

let payload = ToolResultPayload {
tool_call_id: "call-1".to_string(),
tool_name: Some("read".to_string()),
content: serde_json::Value::String("canonical".to_string()),
display_content: Some("display".to_string()),
is_error: false,
summary: "read output".to_string(),
};
let json = serde_json::to_value(&payload).expect("serialize payload");
assert_eq!(
json.get("display_content"),
Some(&serde_json::Value::String("display".to_string()))
);
}

#[test]
fn steer_accepted_event_roundtrips() {
let turn_id = TurnId::new();
Expand Down
Loading
Loading