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 written in response to a confirmed user experience bug: in the current implementation, users see thinking, then “Generating…”, then immediately a completed tool card. They never see an “input-streaming” / “running” state on the tool card. The deferred queue mechanism was designed to solve this, but the user’s observation indicates a different problem.
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 |
| Post-paint deferred queue | N/A (no frontend) | pendingToolResultsRef + useEffect([toolResultTick]) |
❌ Library has no concept of UI rendering |
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. The User-Reported Bug: Missing “Running” State
3.1 Expected Behavior
Per the current Prototyper docs and code, the sequence should be:
User sends message
→ Model streams thinking text (visible in Reasoning block)
→ Model calls write_file
→ Tool card appears: "Processing" badge + Loader2 spinner (input-streaming state)
→ Rust executes write_file (async)
→ Tool card updates: "Completed" badge + CheckCircle (output-available state)
→ Final message saved to chat.json
3.2 Actual Behavior (User Report)
User sends message
→ Model streams thinking text (visible) ✓
→ "Generating..." loader shows during model streaming ✓
→ IMMEDIATELY: write_file shows "Completed" (output-available state)
→ NEVER: "Processing" / "input-streaming" state on tool card
This indicates the tool card renders directly at output-available without ever showing input-streaming.
3.3 Root Cause Analysis
Hypothesis A: Synchronous Batching (Documented Problem)
The deferred queue mechanism exists because Tauri Channel’s transformCallback processes all queued messages synchronously:
while (nextIndex in pendingMessages) {
const message = pendingMessages[nextIndex];
onmessage.call(this, message);
delete pendingMessages[nextIndex];
nextIndex++;
}
Reference: Prototyper node_modules/@tauri-apps/api/core.js:99–105
If ToolResult arrives before JS processes the previous callback (very likely for fast file writes), ToolCall, ToolResult, and Done are executed in one JS call stack.
Evidence — Prototyper handler:
channel.onmessage = (msg) => {
if (msg.event === "ToolCall") {
attachToolCall(pending: true); // Sets pending=true
} else if (msg.event === "ToolResult") {
pendingToolResultsRef.current.push({...});
setToolResultTick(t + 1); // Queues visual update
} else if (msg.event === "Done") {
finalize(); // Synchronously drains queue
}
}
Reference: Prototyper src/hooks/useChat.ts:286–329
finalize() drains the queue synchronously, calling updateLastToolResult(pending: false):
const finalize = (content: string, thinking: string) => {
// Flush any queued tool results synchronously
for (const result of pendingToolResultsRef.current.splice(0)) {
useChatStore.getState().updateLastToolResult(entityId, result.tool, ...);
useChatStore.getState().patchLastToolCallPath(entityId, result.tool, ...);
}
// ... build final message, persist
}
Reference: Prototyper src/hooks/useChat.ts:259–284
The pending: false is set inside finalize() before isStreaming is set to false, but this happens IN THE SAME synchronous batch as attachToolCall(pending: true). React will batch these updates and render once.
If React batches:
attachToolCall(pending: true)→ store updateupdateLastToolResult(pending: false)→ store updatesetStreaming(false)→ store update
Then React renders with pending: false already applied. The input-streaming state never paints.
Hypothesis B: React Concurrent Batching (More Likely)
React 19’s concurrent features may batch the store updates from Step 16 (attachToolCall) and Step 17 (ToolResult handler) into a single render. If finalize() runs in the same batch and flushes pending to false, the intermediate pending: true state is never committed.
React renders the message list with:
message.toolCalls = [{ tool: "write_file", pending: false, ... }]
The MessageList.tsx toolPartFromRecord function maps:
const state = tc.pending
? "input-streaming"
: tc.success === false
? "output-error"
: "output-available"
Reference: Prototyper src/components/chat/MessageList.tsx:25–30
Since pending: false → state is "output-available". The card shows “Completed” immediately.
Why the deferred queue doesn’t help here
The useEffect that drains the queue:
useEffect(() => {
if (toolResultTick === 0) return;
for (const result of pendingToolResultsRef.current.splice(0)) {
updateLastToolResult(entityId, result.tool, result.output, result.success);
patchLastToolCallPath(entityId, result.tool, result.path ?? "");
}
}, [toolResultTick, entityId]);
Reference: Prototyper src/hooks/useChat.ts:190–201
This effect fires after paint, but only if ToolResult handler ran earlier and set toolResultTick. However, finalize() runs in the SAME synchronous batch as the Done event, and it also drains the queue. If finalize runs before the effect, the effect finds an empty queue and is a no-op.
The user’s observed behavior matches this exactly.
Conclusion: The deferred queue mechanism is correct in theory, but React’s batching behavior + finalize’s synchronous flush defeat its purpose when events are batched.
3.4 Why the Library Can’t Fix This
The open-agent-sdk has no concept of a frontend, no concept of React, no concept of browser paint cycles, and no concept of deferred visual updates. It is a CLI-focused library.
Even if we adopted it, the library’s auto-execution mode would make the problem worse by hiding the entire tool execution phase from the frontend entirely.
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 Fix: The Missing “Running” State Bug
The most critical issue. Options:
Option A: Split finalize into two phases
Don’t flush the deferred queue inside finalize(). Instead:
onmessage(Done)→ setsisStreaming = false, preservespending: trueon tool calls- React renders with
pending: true— spinner visible useEffect([toolResultTick])fires post-paint → drains queue → setspending: false- React renders final state
Problem: Chat JSON would be persisted with pending: true.
Option B: Persist with pending=false, render with pending=true
Keep the current finalize() synchronous flush (so JSON is correct), but add a renderingToolCalls state that tracks the visual pending: true independently of the store state.
const renderingToolCallsRef = useRef<Set<string>>(new Set());
// On ToolCall: add to rendering set
renderingToolCallsRef.current.add(toolUseId);
// On ToolResult: remove from set, but only after useEffect
useEffect(() => {
// drain queue
const ids = pendingToolResultsRef.current.map(r => r.tool);
for (const id of ids) renderingToolCallsRef.current.delete(id);
}, [toolResultTick]);
This decouples persisted state from visual state.
Option C: Force a render between ToolCall and Done
Use flushSync to force React to render before Done arrives:
import { flushSync } from 'react-dom';
if (msg.event === "ToolCall") {
flushSync(() => {
useChatStore.getState().attachToolCall(entityId, ...);
});
}
Caveat: flushSync is generally discouraged but may be acceptable here. However, since Tauri Channel calls onmessage synchronously, flushSync inside the handler may still not actually yield to the browser before ToolResult arrives.
Option D: Use requestAnimationFrame / setTimeout(0) to break the synchronous chain
Instead of sending ToolResult and Done immediately after tool execution, insert a 1ms yield:
// In agent_loop.rs after execute_tool
channel.send(CompletionEvent::ToolResult { ... }).await?;
tokio::time::sleep(Duration::from_millis(1)).await; // Yield control
channel.send(CompletionEvent::Done).await?;
This ensures JS has a chance to process the ToolCall callback and render before ToolResult arrives. The 1ms delay is imperceptible.
This is the simplest and most reliable fix. It doesn’t require frontend changes.
5. Exact Source References
Prototyper Code
| File | Line Range | Role |
|---|---|---|
src/hooks/useChat.ts |
23–75 | buildApiMessages() — Ollama tool history |
src/hooks/useChat.ts |
190–201 | useEffect([toolResultTick]) — deferred queue drainer |
src/hooks/useChat.ts |
259–284 | finalize() — flushes queue synchronously |
src/hooks/useChat.ts |
286–329 | channel.onmessage handlers (Chunk, ToolCall, ToolResult, Done) |
src/stores/chatStore.ts |
71–81 | attachToolCall() — sets pending: true |
src/stores/chatStore.ts |
83–99 | updateLastToolResult() — sets pending: false |
src/stores/chatStore.ts |
101–117 | patchLastToolCallPath() — patches path |
src/components/chat/MessageList.tsx |
25–30 | toolPartFromRecord() — maps pending to state |
src/components/chat/MessageList.tsx |
175–176 | Loader rendering logic |
src/components/chat/MessageList.tsx |
199–213 | Tool card + “Generating…” loader rendering |
src/components/ui/tool.tsx |
46–71 | input-streaming state rendering (Loader2 + “Processing”) |
src-tauri/src/agent/agent_loop.rs |
46–81 | stream_turn() — manual history fix |
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 |