Open Agent SDK Analysis — Prototyper Integration Feasibility
Version audited:
open-agent-sdkv0.6.4 (commit6b48021) Clone:/tmp/open-agent-sdk-rust-checkDate: 2026-04-30
Executive Summary
The open-agent-sdk cannot be used as a drop-in replacement for Prototyper’s agent loop. It is architecturally incompatible on four fundamental axes: API format (OpenAI SSE vs Ollama NDJSON), cancellation mechanism (AtomicBool vs tokio_util::CancellationToken), thinking support (absent), and frontend visibility during tool execution (completely opaque in auto mode).
However, several patterns are worth adopting: hooks, retry logic, and tool builder pattern.
This document was originally written in response to a tool-card “running” state bug (see §3 for what changed and how it was resolved).
1. Library Architecture
1.1 API Format Target
The library targets the OpenAI-compatible /v1/chat/completions endpoint. It does NOT speak the Ollama native /api/chat protocol.
Evidence:
let url = format!("{}/chat/completions", options.base_url());
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:1309
It expects SSE (Server-Sent Events) with the OpenAI incremental delta format — data: {...}\n\n — not Ollama’s newline-delimited JSON:
# OpenAI SSE (library expects this)
data: {"id":"chatcmpl-123","object":"chat.completion.chunk",...}
data: [DONE]
# Ollama NDJSON (Prototyper uses this)
{"message":{"content":"H","role":"assistant"},"done":false}
{"message":{"content":"i","role":"assistant"},"done":false}
Evidence — library SSE parser:
pub fn parse_sse_stream(body: reqwest::Response) -> Pin<Box<dyn Stream<Item = Result<OpenAIChunk>> + Send>> {
body.bytes_stream().filter_map(move |result| async move {
let text = String::from_utf8_lossy(&bytes);
for line in text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" { continue; }
let chunk: OpenAIChunk = serde_json::from_str(data)?;
return Some(Ok(chunk));
}
}
})
}
Reference: /tmp/open-agent-sdk-rust-check/src/utils.rs:436–483
1.2 Streaming Format: OpenAI Deltas
OpenAI delivers streaming content as incremental deltas. A single tool call may be split across dozens of SSE chunks:
Chunk 1: { tool_calls: [{ index: 0, id: "call_abc123", function: { name: "get_weather" } }] }
Chunk 2: { tool_calls: [{ index: 0, function: { arguments: "{\"loc" } }] }
Chunk 3: { tool_calls: [{ index: 0, function: { arguments: "ation" } }] }
Chunk 4: { finish_reason: "tool_calls" }
The library solves this with a ToolCallAggregator that buffers and assembles partial tool calls.
Evidence:
pub fn process_chunk(&mut self, chunk: OpenAIChunk) -> Result<Vec<ContentBlock>> {
// Phase 2: Accumulate tool call deltas
if let Some(tool_calls) = choice.delta.tool_calls {
for tool_call in tool_calls {
let entry = self.tool_calls.entry(tool_call.index).or_default();
if let Some(id) = tool_call.id { entry.id = Some(id); }
if let Some(function) = tool_call.function {
if let Some(name) = function.name { entry.name = Some(name); }
if let Some(args) = function.arguments { entry.arguments.push_str(&args); }
}
}
}
}
Reference: /tmp/open-agent-sdk-rust-check/src/utils.rs:279–310
For Ollama, this aggregation is unnecessary. Ollama delivers complete tool_calls as a JSON array on the done=true chunk alone. Prototyper’s current architecture doesn’t need a ToolCallAggregator.
1.3 Two Operating Modes
| Mode | Description | Frontend Visibility |
|---|---|---|
| Manual | Caller receives ToolUseBlock, executes tool, calls add_tool_result(), then send("") |
✅ Full — every step is visible to the caller |
| Auto | Library internally collects ALL blocks, executes tools, loops, and only emits final text | ❌ Nothing — tool execution is completely hidden |
Evidence — auto mode implementation:
async fn auto_execute_loop(&mut self) -> Result<Vec<ContentBlock>> {
loop {
// STEP 1: Collect ALL blocks from current stream into memory
let blocks = self.collect_all_blocks().await?;
// STEP 2: Separate text from tool use
let mut text_blocks = Vec::new();
let mut tool_blocks = Vec::new();
for block in blocks {
match block {
ContentBlock::Text(_) => text_blocks.push(block),
ContentBlock::ToolUse(_) => tool_blocks.push(block),
_ => {}
}
}
// If no tool calls, return text
if tool_blocks.is_empty() {
return Ok(text_blocks);
}
// Execute tools, add results to history, continue loop
for block in tool_blocks {
if let ContentBlock::ToolUse(tool_use) = block {
let result = self.execute_tool_internal(tool_use.name(), tool_input).await?;
self.history.push(Message::tool(result));
}
}
self.send("").await?; // Continue to next iteration
}
}
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:1425–1676
Reference — collect_all_blocks explicitly buffers the entire response:
async fn collect_all_blocks(&mut self) -> Result<Vec<ContentBlock>> {
let mut blocks = Vec::new();
while let Some(block) = self.receive_one().await? {
blocks.push(block);
}
Ok(blocks)
}
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:1444–1461
1.4 Client State
pub struct Client {
options: AgentOptions, // model, base_url, tools, hooks
history: Vec<Message>, // conversation history
current_stream: Option<ContentStream>, // active SSE stream
interrupted: Arc<AtomicBool>, // cancellation flag
auto_exec_buffer: Vec<ContentBlock>, // buffered blocks in auto mode
auto_exec_index: usize, // read position in auto_exec_buffer
manual_receive_buffer: Vec<ContentBlock>, // blocks in manual mode
}
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:831–903
Cancellation uses Arc<AtomicBool>, checked on every receive_one() call:
if self.interrupted.load(Ordering::SeqCst) {
return Ok(None);
}
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:1398–1406
This is less cooperative than Prototyper’s CancellationToken + tokio::select! approach. The library’s interrupt drops the next receive_one() call, but the underlying HTTP stream may continue running until the next chunk arrives.
1.5 Tool Builder Pattern
let add_tool = tool("add", "Add two numbers")
.param("a", "number")
.param("b", "number")
.build(|args| async move {
let a = args["a"].as_f64().unwrap_or(0.0);
let b = args["b"].as_f64().unwrap_or(0.0);
Ok(json!({"result": a + b}))
});
Reference: /tmp/open-agent-sdk-rust-check/examples/calculator_tools.rs:13–20
1.6 Hooks System
Three hook types:
pub struct Hooks {
pre_tool_use: Vec<PreToolUseHook>, // Before executing tool
post_tool_use: Vec<PostToolUseHook>, // After tool result added to history
user_prompt_submit: Vec<UserPromptSubmitHook>, // Before sending user prompt
}
Reference: /tmp/open-agent-sdk-rust-check/src/hooks.rs
Execution order: sequential, first non-None decision wins (short-circuit).
Reference: /tmp/open-agent-sdk-rust-check/src/client.rs:1645–1658
Evidence — PreToolUse hook in production example:
let hooks = Hooks::new()
.add_pre_tool_use(|event: PreToolUseEvent| async move {
if event.tool_name == "delete" || event.tool_name == "modify_system" {
return Some(HookDecision::block("Safety policy violation"));
}
// Validation: division by zero
if event.tool_name == "calculate" {
if let Some(b) = event.tool_input.get("b").and_then(|v| v.as_f64()) {
if b == 0.0 {
return Some(HookDecision::block("Division by zero prevented"));
}
}
}
Some(HookDecision::continue_())
})
.add_post_tool_use(|event: PostToolUseEvent| async move {
// Audit logging + metadata injection
log.lock().unwrap().push(format!("[{}] {} -> {}", ...));
Some(HookDecision::modify_input(json!(enhanced), "Added metadata"))
});
Reference: /tmp/open-agent-sdk-rust-check/examples/multi_tool_agent.rs:142–206
1.7 Context Management
Token estimation and manual truncation utilities are exposed as functions, not automatic behavior:
let tokens = estimate_tokens(client.history());
if is_approaching_limit(client.history(), token_limit, margin) {
let truncated = truncate_messages(client.history(), 10, true);
*client.history_mut() = truncated;
}
Reference: /tmp/open-agent-sdk-rust-check/src/context.rs
This is consistent with Prototyper’s philosophy of manual history management, though we currently lack the token estimation helper.
2. Comparison: Library vs Prototyper
2.1 Feature Matrix
| Feature | Library | Prototyper | Fit |
|---|---|---|---|
| API format | OpenAI SSE (/v1/chat/completions) |
Ollama native (/api/chat) |
❌ Different formats |
| Streaming text | SSE delta.content incremental chunks |
Ollama NDJSON message.content tokens |
⚠️ Both stream but format differs |
| Thinking / reasoning | NOT SUPPORTED — no thinking field |
thinking streaming via rAF batching |
❌ Prototyper supports this |
| Tool call aggregation | ToolCallAggregator for incremental deltas |
Captures complete tool_calls on done=true |
⚠️ Library pattern not needed for Ollama |
| Tool execution visibility | Manual: visible. Auto: completely hidden | Always visible via ToolCall → ToolResult → Done events |
❌ Auto mode is a dealbreaker |
| Frontend IPC | None (standalone HTTP client) | Tauri Channel<CompletionEvent> |
❌ Library has no Tauri integration |
| Cancellation | Arc<AtomicBool> + check on receive |
CancellationToken + tokio::select! |
✅ Both work; Token is more idiomatic with Ollama |
| Hooks | PreToolUse, PostToolUse, UserPromptSubmit | None | ✅ Library has this |
| Retry logic | Exponential backoff with jitter | None | ✅ Library has this |
| Context truncation | estimate_tokens(), truncate_messages() |
None | ✅ Library has this |
| Vision/multimodal | ImageBlock (URL, file, base64) |
Not yet implemented | ✅ Library pattern could inspire |
| Hooks | Async closures with builder | None | ✅ Could adopt pattern |
2.2 Critical Incompatibility: Auto-Execution in a Desktop App
The library’s most attractive feature is also its biggest problem for a desktop app.
In auto-execution mode (the mode you’d want for a simple user experience), the library:
- Buffers the entire assistant response in memory
- Separates text blocks from tool use blocks
- Executes tools automatically
- Continues the conversation internally
- Only emits final text to the caller
During steps 1–4, the frontend receives absolutely nothing. No text tokens, no tool cards, no spinners, no progress indicators. A 30-second tool execution (e.g., a slow bash command) would appear as a frozen UI.
Evidence: The library itself acknowledges this in its example code:
// auto_execution_demo.rs:127–135
ContentBlock::ToolUse(_) => {
// Should NOT receive ToolUse blocks in auto mode!
println!("⚠️ Unexpected: Received ToolUse block");
}
ContentBlock::ToolResult(_) => {
// Should NOT receive ToolResult blocks either!
println!("⚠️ Unexpected: Received ToolResult block");
}
Reference: /tmp/open-agent-sdk-rust-check/examples/auto_execution_demo.rs:127–135
In a Cursor-like desktop app, this is unacceptable. The user must see:
- Every streaming text token as it arrives
- The moment a tool call is requested
- A “Processing…” spinner during tool execution
- The tool result when complete
Prototyper solves this via its event-driven architecture:
Chunk→ text appears liveToolCall→ tool card appears withpending: trueToolResult→ spinner transitions to completeDone→ conversation finalizes
Reference: Prototyper src-tauri/src/commands/ai.rs:36–43 (CompletionEvent enum)
3. Resolved: The Missing “Running” State Bug
Tool cards used to render straight to “Completed” without ever showing a “Processing” / input-streaming state. The shipped fix: attachToolCall / resolveToolCall write straight to the Zustand store as each Channel event arrives (src/hooks/chat/streamHandler.ts:94–96,144), and tool.tsx derives the rendered card state directly from { pending, success }.
See Chat Stream & Tool Flow → Tauri Channel Batching for the verified current mechanism.
4. Recommendations
4.1 Reject: Full Library Replacement
| Dimension | Why Incompatible |
|---|---|
| API format | OpenAI SSE ≠ Ollama NDJSON. We’d need a whole new parser (parse_sse_stream is OpenAI-only). |
| Thinking support | Library has no thinking field. Prototyper relies on this for reasoning display. |
| Tauri / IPC | Library has zero Tauri integration. We’d need a full bridge layer. |
| Frontend visibility | Auto mode hides tool execution. Manual mode is verbose and loses convenience. |
| Cancellation | AtomicBool drops next receive call. CancellationToken drops the HTTP connection itself. |
| History model | Library uses Vec<Message> with nested ContentBlock arrays. Prototyper uses flat ChatMessage with toolCalls. Translation required. |
4.2 Adopt: Hook Pattern
Create src-tauri/src/agent/hooks.rs with:
pub enum HookDecision {
Continue,
Block { reason: String },
ModifyInput { input: serde_json::Value, reason: String },
}
pub struct Hooks {
pre_tool_use: Vec<Box<dyn Fn(&PreToolUseEvent) -> BoxFuture<HookDecision>>>,
post_tool_use: Vec<Box<dyn Fn(&PostToolUseEvent) -> BoxFuture<Option<serde_json::Value>>>>,
}
Use cases for Prototyper:
- PreToolUse: Validate file paths (path traversal guard), confirm destructive operations (bash commands), rate-limit tool calls
- PostToolUse: Audit log all tool executions, inject metadata into tool results, send telemetry
Reference pattern: /tmp/open-agent-sdk-rust-check/src/hooks.rs
4.3 Adopt: Retry Logic
Add exponential backoff to generate_completion_stream for transient HTTP failures (connection refused, timeout, 503).
use crate::retry::RetryConfig;
let result = RetryConfig::default()
.max_attempts(3)
.base_delay_ms(500)
.with_jitter(true)
.execute(|| async {
// HTTP request
}).await;
Reference pattern: /tmp/open-agent-sdk-rust-check/src/retry.rs
4.4 Adopt: Tool Builder Pattern
Replace JSON blobs in src-tauri/src/agent/tools.rs with a builder:
let write_file = Tool::new("write_file", "Write content to a file")
.param("content", "string", "The file content to write")
.build(|args| async move {
let content = args["content"].as_str().unwrap_or("");
tokio::fs::write(path, content).await?;
Ok(json!({"success": true}))
});
This doesn’t change behavior but improves developer experience.
Reference pattern: /tmp/open-agent-sdk-rust-check/src/tools.rs
4.5 Adopt: Token Estimation
Add estimate_tokens() and is_approaching_limit() before sending to prevent context overflow.
let token_count = estimate_tokens(&history);
if token_count > MAX_CONTEXT_TOKENS * 0.9 {
history = truncate_messages(&history, 10, true);
}
Reference pattern: /tmp/open-agent-sdk-rust-check/src/context.rs
4.6 Resolved: The Missing “Running” State Bug
This was the original trigger for this analysis. The shipped fix was simpler than any option considered here: stop deferring at all. attachToolCall / resolveToolCall write straight to the Zustand store as each Channel event arrives, and tool.tsx renders directly off { pending, success }. See §3.
5. Exact Source References
Prototyper Code
| File | Line Range | Role |
|---|---|---|
src/hooks/useChat.ts |
23–75 | buildApiMessages() — Ollama tool history |
src/hooks/chat/streamHandler.ts |
53–71 | finalize() — persists final message, writes chat.json |
src/hooks/chat/streamHandler.ts |
73–177 | channel.onmessage handlers (Chunk, ToolCall, ToolResult, Done) |
src/stores/chatStore.ts |
77–86 | attachToolCall() — pushes { pending: true } |
src/stores/chatStore.ts |
88–105 | resolveToolCall() — mutates matching entry to { pending: false, success } |
src/components/ui/tool.tsx |
15–27 | ToolPart state union + state derivation from { pending, success } |
src/components/ui/tool.tsx |
171–187 | input-streaming / output-available / output-error rendering |
src-tauri/src/agent/agent_loop.rs |
47–55,169–172 | manual history fix — preserves tool_calls on the assistant message |
src-tauri/src/agent/agent_loop.rs |
170–189 | Tool execution + ToolCall / ToolResult event send |
src-tauri/src/agent/agent_loop.rs |
191–200 | wrote_file = true → break + Done |
src-tauri/src/commands/ai.rs |
461–550 | generate_completion_stream — tokio::spawn, CancellationToken |
src-tauri/src/commands/ai.rs |
36–43 | CompletionEvent enum definitions |
node_modules/@tauri-apps/api/core.js |
99–105 | while-loop transformCallback batching |
Open Agent SDK
| File | Line Range | Role |
|---|---|---|
src/client.rs |
831–903 | Client struct definition |
src/client.rs |
905–1375 | Client::send() — builds request, runs hooks, stores stream |
src/client.rs |
1377–1423 | receive_one() — core streaming logic |
src/client.rs |
1425–1461 | collect_all_blocks() — buffers entire response |
src/client.rs |
1499–1676 | auto_execute_loop() — auto-execution loop |
src/utils.rs |
150–352 | ToolCallAggregator — incremental tool call assembly |
src/utils.rs |
436–483 | parse_sse_stream() — OpenAI SSE parsing |
src/hooks.rs |
Full file | Hooks, HookDecision, hook execution |
src/types.rs |
Full file | AgentOptions, ContentBlock, Message, OpenAI types |
src/retry.rs |
Full file | Exponential backoff with jitter |
src/context.rs |
Full file | Token estimation, context truncation |
examples/multi_tool_agent.rs |
142–206 | Production agent with hooks + 5 tools |
examples/auto_execution_demo.rs |
127–135 | Acknowledges tool blocks hidden in auto mode |
examples/interrupt_demo.rs |
146–179 | Concurrent interrupt with Arc<AtomicBool> |
Cargo.toml |
31–32 | Dependencies: reqwest, tokio |