cargo init

This commit is contained in:
Keisuke Hirata 2026-04-04 03:30:49 +09:00
parent d0a26d4c5d
commit 490ed0ca7c
86 changed files with 16512 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
.direnv
*.local

2108
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[workspace]
resolver = "2"
members = [
"crates/insomnia",
"crates/llm-worker",
"crates/llm-worker-macros",
]
[workspace.package]
edition = "2024"
license = "MIT"

View File

@ -0,0 +1,6 @@
[package]
name = "insomnia"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

View File

@ -0,0 +1,14 @@
[package]
name = "llm-worker-macros"
description = "llm-worker's proc macros"
version = "0.2.0"
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }

View File

@ -0,0 +1,321 @@
//! llm-worker-macros - Procedural macros for Tool generation
//!
//! Provides `#[tool_registry]` and `#[tool]` macros to
//! automatically generate `Tool` trait implementations from user-defined methods.
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
};
/// Macro applied to an `impl` block that generates tools from methods marked with `#[tool]`.
///
/// # Example
/// ```ignore
/// #[tool_registry]
/// impl MyApp {
/// /// Get user information
/// /// Retrieves a user from the database by their ID.
/// #[tool]
/// async fn get_user(&self, user_id: String) -> Result<User, Error> { ... }
/// }
/// ```
///
/// This generates:
/// - `GetUserArgs` struct (for arguments)
/// - `Tool_get_user` struct (Tool wrapper)
/// - `impl Tool for Tool_get_user`
/// - `impl MyApp { fn get_user_tool(&self) -> Tool_get_user }`
#[proc_macro_attribute]
pub fn tool_registry(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut impl_block = parse_macro_input!(item as ItemImpl);
let self_ty = &impl_block.self_ty;
let mut generated_items = Vec::new();
for item in &mut impl_block.items {
if let ImplItem::Fn(method) = item {
// Look for #[tool] attribute
let mut is_tool = false;
// Iterate through attributes to check for tool and remove it
method.attrs.retain(|attr| {
if attr.path().is_ident("tool") {
is_tool = true;
false // Remove the attribute
} else {
true
}
});
if is_tool {
let tool_impl = generate_tool_impl(self_ty, method);
generated_items.push(tool_impl);
}
}
}
let expanded = quote! {
#impl_block
#(#generated_items)*
};
TokenStream::from(expanded)
}
/// Extract description from doc comments
fn extract_doc_comment(attrs: &[Attribute]) -> String {
let mut lines = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc") {
if let Meta::NameValue(meta) = &attr.meta {
if let syn::Expr::Lit(expr_lit) = &meta.value {
if let Lit::Str(lit_str) = &expr_lit.lit {
let line = lit_str.value();
// Remove only the leading space (after ///)
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
lines.push(trimmed.to_string());
}
}
}
}
}
lines.join("\n")
}
/// Extract description from #[description = "..."] attribute
fn extract_description_attr(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("description") {
if let Meta::NameValue(meta) = &attr.meta {
if let syn::Expr::Lit(expr_lit) = &meta.value {
if let Lit::Str(lit_str) = &expr_lit.lit {
return Some(lit_str.value());
}
}
}
}
}
None
}
/// Generate Tool implementation from a method
fn generate_tool_impl(self_ty: &Type, method: &syn::ImplItemFn) -> proc_macro2::TokenStream {
let sig = &method.sig;
let method_name = &sig.ident;
let tool_name = method_name.to_string();
// Generate struct names (convert to PascalCase)
let pascal_name = to_pascal_case(&method_name.to_string());
let tool_struct_name = format_ident!("Tool{}", pascal_name);
let args_struct_name = format_ident!("{}Args", pascal_name);
let definition_name = format_ident!("{}_definition", method_name);
// Get description from doc comments
let description = extract_doc_comment(&method.attrs);
let description = if description.is_empty() {
format!("Tool: {}", tool_name)
} else {
description
};
// Parse arguments (excluding self)
let args: Vec<_> = sig
.inputs
.iter()
.filter_map(|arg| {
if let FnArg::Typed(pat_type) = arg {
Some(pat_type)
} else {
None // Exclude self
}
})
.collect();
// Generate argument struct fields
let arg_fields: Vec<_> = args
.iter()
.map(|pat_type| {
let pat = &pat_type.pat;
let ty = &pat_type.ty;
let desc = extract_description_attr(&pat_type.attrs);
// Extract identifier from pattern
let field_name = if let Pat::Ident(pat_ident) = pat.as_ref() {
&pat_ident.ident
} else {
panic!("Only simple identifiers are supported for tool arguments");
};
// Convert #[description] to schemars doc if present
if let Some(desc_str) = desc {
quote! {
#[schemars(description = #desc_str)]
pub #field_name: #ty
}
} else {
quote! {
pub #field_name: #ty
}
}
})
.collect();
// Code to expand arguments in execute
let arg_names: Vec<_> = args
.iter()
.map(|pat_type| {
if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
let ident = &pat_ident.ident;
quote! { args.#ident }
} else {
panic!("Only simple identifiers are supported");
}
})
.collect();
// Check if method is async
let is_async = sig.asyncness.is_some();
// Parse return type and determine if Result
let awaiter = if is_async {
quote! { .await }
} else {
quote! {}
};
// Determine if return type is Result
let result_handling = if is_result_type(&sig.output) {
quote! {
match result {
Ok(val) => Ok(format!("{:?}", val)),
Err(e) => Err(::llm_worker::tool::ToolError::ExecutionFailed(format!("{}", e))),
}
}
} else {
quote! {
Ok(format!("{:?}", result))
}
};
// Create empty Args struct if no arguments
let args_struct_def = if arg_fields.is_empty() {
quote! {
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct #args_struct_name {}
}
} else {
quote! {
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct #args_struct_name {
#(#arg_fields),*
}
}
};
// Execute body handling for no arguments case
let execute_body = if args.is_empty() {
quote! {
// Allow empty JSON object even with no arguments
let _: #args_struct_name = serde_json::from_str(input_json)
.unwrap_or(#args_struct_name {});
let result = self.ctx.#method_name()#awaiter;
#result_handling
}
} else {
quote! {
let args: #args_struct_name = serde_json::from_str(input_json)
.map_err(|e| ::llm_worker::tool::ToolError::InvalidArgument(e.to_string()))?;
let result = self.ctx.#method_name(#(#arg_names),*)#awaiter;
#result_handling
}
};
quote! {
#args_struct_def
#[derive(Clone)]
pub struct #tool_struct_name {
ctx: #self_ty,
}
#[async_trait::async_trait]
impl ::llm_worker::tool::Tool for #tool_struct_name {
async fn execute(&self, input_json: &str) -> Result<String, ::llm_worker::tool::ToolError> {
#execute_body
}
}
impl #self_ty {
/// Get ToolDefinition (for registering with Worker)
pub fn #definition_name(&self) -> ::llm_worker::tool::ToolDefinition {
let ctx = self.clone();
::std::sync::Arc::new(move || {
let schema = schemars::schema_for!(#args_struct_name);
let meta = ::llm_worker::tool::ToolMeta::new(#tool_name)
.description(#description)
.input_schema(serde_json::to_value(schema).unwrap_or(serde_json::json!({})));
let tool: ::std::sync::Arc<dyn ::llm_worker::tool::Tool> =
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
(meta, tool)
})
}
}
}
}
/// Determine if return type is Result
fn is_result_type(return_type: &ReturnType) -> bool {
match return_type {
ReturnType::Default => false,
ReturnType::Type(_, ty) => {
// For Type::Path, check if last segment is "Result"
if let Type::Path(type_path) = ty.as_ref() {
if let Some(segment) = type_path.path.segments.last() {
return segment.ident == "Result";
}
}
false
}
}
}
/// Convert snake_case to PascalCase
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
/// Marker attribute. Does nothing here as it's processed by `tool_registry`.
#[proc_macro_attribute]
pub fn tool(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}
/// Marker for argument attributes. Interpreted by `tool_registry` during parsing.
///
/// # Example
/// ```ignore
/// #[tool]
/// async fn get_user(
/// &self,
/// #[description = "The ID of the user to retrieve"] user_id: String
/// ) -> Result<User, Error> { ... }
/// ```
#[proc_macro_attribute]
pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}

View File

@ -0,0 +1,27 @@
[package]
name = "llm-worker"
description = "A library for building autonomous LLM-powered systems"
version = "0.2.1"
edition.workspace = true
license.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tracing = "0.1"
async-trait = "0.1"
futures = "0.3"
tokio = { version = "1.49", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7"
reqwest = { version = "0.13.1", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
eventsource-stream = "0.2"
llm-worker-macros = { path = "../llm-worker-macros", version = "0.2" }
[dev-dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
schemars = "1.2"
tempfile = "3.24"
dotenv = "0.15"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
trybuild = "1.0.116"

View File

@ -0,0 +1,219 @@
//! Test fixture recording tool
//!
//! Records API responses for defined scenarios.
//!
//! ## Usage
//!
//! ```bash
//! # Show available scenarios
//! cargo run --example record_test_fixtures
//!
//! # Record specific scenario
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- simple_text
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- tool_call
//!
//! # Record all scenarios
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- --all
//! ```
mod recorder;
mod scenarios;
use clap::{Parser, ValueEnum};
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
use llm_worker::llm_client::providers::gemini::GeminiClient;
use llm_worker::llm_client::providers::openai::OpenAIClient;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Scenario name
#[arg(short, long)]
scenario: Option<String>,
/// Run all scenarios
#[arg(long, default_value_t = false)]
all: bool,
/// Client to use
#[arg(short, long, value_enum, default_value_t = ClientType::Anthropic)]
client: ClientType,
/// Model to use (optional, defaults per client)
#[arg(short, long)]
model: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum ClientType {
Anthropic,
Gemini,
Openai,
Ollama,
}
async fn run_scenario_with_anthropic(
scenario: &scenarios::TestScenario,
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.expect("ANTHROPIC_API_KEY environment variable must be set");
let model = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
let client = AnthropicClient::new(&api_key, model);
recorder::record_request(
&client,
scenario.request.clone(),
scenario.name,
scenario.output_name,
subdir,
model,
)
.await?;
Ok(())
}
async fn run_scenario_with_openai(
scenario: &scenarios::TestScenario,
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key =
std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY environment variable must be set");
let model = model.as_deref().unwrap_or("gpt-4o");
let client = OpenAIClient::new(&api_key, model);
recorder::record_request(
&client,
scenario.request.clone(),
scenario.name,
scenario.output_name,
subdir,
model,
)
.await?;
Ok(())
}
async fn run_scenario_with_ollama(
scenario: &scenarios::TestScenario,
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use llm_worker::llm_client::providers::ollama::OllamaClient;
// Ollama typically runs local, no key needed or placeholder
let model = model.as_deref().unwrap_or("llama3"); // default example
let client = OllamaClient::new(model); // base_url placeholder, handled by client default
recorder::record_request(
&client,
scenario.request.clone(),
scenario.name,
scenario.output_name,
subdir,
model,
)
.await?;
Ok(())
}
async fn run_scenario_with_gemini(
scenario: &scenarios::TestScenario,
subdir: &str,
model: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key =
std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY environment variable must be set");
let model = model.as_deref().unwrap_or("gemini-2.0-flash");
let client = GeminiClient::new(&api_key, model);
recorder::record_request(
&client,
scenario.request.clone(),
scenario.name,
scenario.output_name,
subdir,
model,
)
.await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv::dotenv().ok();
let args = Args::parse();
if !args.all && args.scenario.is_none() {
use clap::CommandFactory;
let mut cmd = Args::command();
cmd.error(
clap::error::ErrorKind::MissingRequiredArgument,
"Either --all or --scenario <SCENARIO> must be provided",
)
.exit();
}
let all_scenarios = scenarios::scenarios();
// Determine scenarios to run
let scenarios_to_run: Vec<_> = if args.all {
all_scenarios
} else {
let scenario_name = args.scenario.as_ref().unwrap();
let found: Vec<_> = all_scenarios
.into_iter()
.filter(|s| s.output_name == scenario_name)
.collect();
if found.is_empty() {
eprintln!("Error: Unknown scenario '{}'", scenario_name);
// Verify correct name by listing
println!("Available scenarios:");
for s in scenarios::scenarios() {
println!(" {}", s.output_name);
}
std::process::exit(1);
}
found
};
println!("=== Test Fixture Generator ===");
println!("Client: {:?}", args.client);
if let Some(ref m) = args.model {
println!("Model: {}", m);
}
println!("Scenarios: {}\n", scenarios_to_run.len());
let subdir = match args.client {
ClientType::Anthropic => "anthropic",
ClientType::Gemini => "gemini",
ClientType::Openai => "openai",
ClientType::Ollama => "ollama",
};
// Scenario filtering is already done in main.rs logic
// Here we just execute in a simple loop
for scenario in scenarios_to_run {
match args.client {
ClientType::Anthropic => {
run_scenario_with_anthropic(&scenario, subdir, args.model.clone()).await?
}
ClientType::Gemini => {
run_scenario_with_gemini(&scenario, subdir, args.model.clone()).await?
}
ClientType::Openai => {
run_scenario_with_openai(&scenario, subdir, args.model.clone()).await?
}
ClientType::Ollama => {
run_scenario_with_ollama(&scenario, subdir, args.model.clone()).await?
}
}
}
println!("\n✅ Done!");
println!("Run tests with: cargo test -p worker");
Ok(())
}

View File

@ -0,0 +1,101 @@
//! Test fixture recording mechanism
//!
//! Saves events to files in JSONL format
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use llm_worker::llm_client::{LlmClient, Request};
/// Recorded event
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct RecordedEvent {
pub elapsed_ms: u64,
pub event_type: String,
pub data: String,
}
/// Session metadata
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SessionMetadata {
pub timestamp: u64,
pub model: String,
pub description: String,
}
/// Save event sequence to file
pub fn save_fixture(
path: impl AsRef<Path>,
metadata: &SessionMetadata,
events: &[RecordedEvent],
) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
writeln!(writer, "{}", serde_json::to_string(metadata)?)?;
for event in events {
writeln!(writer, "{}", serde_json::to_string(event)?)?;
}
writer.flush()?;
Ok(())
}
/// Send request and record events
pub async fn record_request<C: LlmClient>(
client: &C,
request: Request,
description: &str,
output_name: &str,
subdir: &str, // e.g. "anthropic", "openai"
model: &str,
) -> Result<usize, Box<dyn std::error::Error>> {
println!("\n📝 Recording: {}", description);
let start_time = Instant::now();
let mut events: Vec<RecordedEvent> = Vec::new();
let mut stream = client.stream(request).await?;
while let Some(result) = stream.next().await {
let elapsed = start_time.elapsed().as_millis() as u64;
match result {
Ok(event) => {
let event_json = serde_json::to_string(&event)?;
println!(" [{:>6}ms] {:?}", elapsed, event);
events.push(RecordedEvent {
elapsed_ms: elapsed,
event_type: format!("{:?}", std::mem::discriminant(&event)),
data: event_json,
});
}
Err(e) => {
eprintln!(" Error: {}", e);
break;
}
}
}
// Save
let fixtures_dir = Path::new("worker/tests/fixtures").join(subdir);
fs::create_dir_all(&fixtures_dir)?;
let filepath = fixtures_dir.join(format!("{}.jsonl", output_name));
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let metadata = SessionMetadata {
timestamp,
model: model.to_string(),
description: description.to_string(),
};
save_fixture(&filepath, &metadata, &events)?;
let event_count = events.len();
println!(" 💾 Saved: {}", filepath.display());
println!(" 📊 {} events recorded", event_count);
Ok(event_count)
}

View File

@ -0,0 +1,74 @@
//! Test fixture request definitions
//!
//! Defines requests and output file names for each scenario
use llm_worker::llm_client::{Request, ToolDefinition};
/// Test scenario
pub struct TestScenario {
/// Scenario name (description)
pub name: &'static str,
/// Output file name (without extension)
pub output_name: &'static str,
/// Request
pub request: Request,
}
/// Get all test scenarios
pub fn scenarios() -> Vec<TestScenario> {
vec![
simple_text_scenario(),
tool_call_scenario(),
long_text_scenario(),
]
}
/// Simple text response
fn simple_text_scenario() -> TestScenario {
TestScenario {
name: "Simple text response",
output_name: "simple_text",
request: Request::new()
.system("You are a helpful assistant. Be very concise.")
.user("Say hello in one word.")
.max_tokens(50),
}
}
/// Response with tool call
fn tool_call_scenario() -> TestScenario {
let get_weather_tool = ToolDefinition::new("get_weather")
.description("Get the current weather for a city")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
}
},
"required": ["city"]
}));
TestScenario {
name: "Tool call response",
output_name: "tool_call",
request: Request::new()
.system("You are a helpful assistant. Use tools when appropriate.")
.user("What's the weather in Tokyo? Use the get_weather tool.")
.tool(get_weather_tool)
.max_tokens(200),
}
}
/// Long text generation scenario
fn long_text_scenario() -> TestScenario {
TestScenario {
name: "Long text response",
output_name: "long_text",
request: Request::new()
.system("You are a creative writer.")
.user("Write a short story about a robot discovering a garden. It should be at least 300 words.")
.max_tokens(1000),
}
}

View File

@ -0,0 +1,71 @@
//! Worker cancellation demo
//!
//! Example of cancelling from another thread during streaming
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
use llm_worker::{Worker, WorkerResult};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load .env file
dotenv::dotenv().ok();
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let api_key =
std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY environment variable not set");
let client = AnthropicClient::new(&api_key, "claude-sonnet-4-20250514");
let worker = Arc::new(Mutex::new(Worker::new(client)));
println!("🚀 Starting Worker...");
println!("💡 Will cancel after 2 seconds\n");
// Get cancel sender first (without holding lock)
let cancel_tx = {
let w = worker.lock().await;
w.cancel_sender()
};
// Task 1: Run Worker
let worker_clone = worker.clone();
let task = tokio::spawn(async move {
let mut w = worker_clone.lock().await;
println!("📡 Sending request to LLM...");
match w.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
Ok(WorkerResult::Finished) => {
println!("✅ Task completed normally");
}
Ok(WorkerResult::Paused) => {
println!("⏸️ Task paused");
}
Err(e) => {
println!("❌ Task error: {}", e);
}
}
});
// Task 2: Cancel after 2 seconds
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
println!("\n🛑 Cancelling worker...");
let _ = cancel_tx.send(()).await;
});
// Wait for task completion
task.await?;
println!("\n✨ Demo complete!");
Ok(())
}

View File

@ -0,0 +1,496 @@
//! Interactive CLI client using Worker
//!
//! A CLI application for interacting with multiple LLM providers (Anthropic, Gemini, OpenAI, Ollama).
//! Demonstrates tool registration and execution, and streaming response display.
//!
//! ## Usage
//!
//! ```bash
//! # Set API keys in .env file
//! echo "ANTHROPIC_API_KEY=your-api-key" > .env
//! echo "GEMINI_API_KEY=your-api-key" >> .env
//! echo "OPENAI_API_KEY=your-api-key" >> .env
//!
//! # Anthropic (default)
//! cargo run --example worker_cli
//!
//! # Gemini
//! cargo run --example worker_cli -- --provider gemini
//!
//! # OpenAI
//! cargo run --example worker_cli -- --provider openai --model gpt-4o
//!
//! # Ollama (local)
//! cargo run --example worker_cli -- --provider ollama --model llama3.2
//!
//! # With options
//! cargo run --example worker_cli -- --provider anthropic --model claude-3-haiku-20240307 --system "You are a helpful assistant."
//!
//! # Show help
//! cargo run --example worker_cli -- --help
//! ```
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tracing::info;
use tracing_subscriber::EnvFilter;
use clap::{Parser, ValueEnum};
use llm_worker::{
Worker,
hook::{Hook, HookError, PostToolCall, PostToolCallContext, PostToolCallResult},
llm_client::{
LlmClient,
providers::{
anthropic::AnthropicClient, gemini::GeminiClient, ollama::OllamaClient,
openai::OpenAIClient,
},
},
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
};
use llm_worker_macros::tool_registry;
// Required imports for macro expansion
use schemars;
use serde;
// =============================================================================
// Provider Definition
// =============================================================================
/// Available LLM providers
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
enum Provider {
/// Anthropic Claude
#[default]
Anthropic,
/// Google Gemini
Gemini,
/// OpenAI GPT
Openai,
/// Ollama (local)
Ollama,
}
impl Provider {
/// Default model for the provider
fn default_model(&self) -> &'static str {
match self {
Provider::Anthropic => "claude-sonnet-4-20250514",
Provider::Gemini => "gemini-2.0-flash",
Provider::Openai => "gpt-4o",
Provider::Ollama => "llama3.2",
}
}
/// Display name for the provider
fn display_name(&self) -> &'static str {
match self {
Provider::Anthropic => "Anthropic Claude",
Provider::Gemini => "Google Gemini",
Provider::Openai => "OpenAI GPT",
Provider::Ollama => "Ollama (Local)",
}
}
/// Environment variable name for API key
fn env_var_name(&self) -> Option<&'static str> {
match self {
Provider::Anthropic => Some("ANTHROPIC_API_KEY"),
Provider::Gemini => Some("GEMINI_API_KEY"),
Provider::Openai => Some("OPENAI_API_KEY"),
Provider::Ollama => None, // Ollama is local, no key needed
}
}
}
// =============================================================================
// CLI Argument Definition
// =============================================================================
/// Interactive CLI client supporting multiple LLM providers
#[derive(Parser, Debug)]
#[command(name = "worker-cli")]
#[command(about = "Interactive CLI client for multiple LLM providers using Worker")]
#[command(version)]
struct Args {
/// Provider to use
#[arg(long, value_enum, default_value_t = Provider::Anthropic)]
provider: Provider,
/// Model name to use (defaults to provider's default if not specified)
#[arg(short, long)]
model: Option<String>,
/// System prompt
#[arg(short, long)]
system: Option<String>,
/// Disable tools
#[arg(long, default_value = "false")]
no_tools: bool,
/// Initial message (if specified, sends it and exits)
#[arg(short = 'p', long)]
prompt: Option<String>,
/// API key (takes precedence over environment variable)
#[arg(long)]
api_key: Option<String>,
}
// =============================================================================
// Tool Definition
// =============================================================================
/// Application context
#[derive(Clone)]
struct AppContext;
#[tool_registry]
impl AppContext {
/// Get the current date and time
///
/// Returns the system's current date and time.
#[tool]
fn get_current_time(&self) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Simple conversion from Unix timestamp
format!("Current Unix timestamp: {}", now)
}
/// Perform a simple calculation
///
/// Executes arithmetic operations on two numbers.
#[tool]
fn calculate(&self, a: f64, b: f64, operation: String) -> Result<String, String> {
let result = match operation.as_str() {
"add" | "+" => a + b,
"subtract" | "-" => a - b,
"multiply" | "*" => a * b,
"divide" | "/" => {
if b == 0.0 {
return Err("Cannot divide by zero".to_string());
}
a / b
}
_ => return Err(format!("Unknown operation: {}", operation)),
};
Ok(format!("{} {} {} = {}", a, operation, b, result))
}
}
// =============================================================================
// Streaming Display Handlers
// =============================================================================
/// Handler that outputs text in real-time
struct StreamingPrinter {
is_first_delta: Arc<Mutex<bool>>,
}
impl StreamingPrinter {
fn new() -> Self {
Self {
is_first_delta: Arc::new(Mutex::new(true)),
}
}
}
impl Handler<TextBlockKind> for StreamingPrinter {
type Scope = ();
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {
let mut first = self.is_first_delta.lock().unwrap();
if *first {
print!("\n🤖 ");
*first = false;
}
}
TextBlockEvent::Delta(text) => {
print!("{}", text);
io::stdout().flush().ok();
}
TextBlockEvent::Stop(_) => {
println!();
}
}
}
}
/// Handler that displays tool calls
struct ToolCallPrinter {
call_names: Arc<Mutex<HashMap<String, String>>>,
}
impl ToolCallPrinter {
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
Self { call_names }
}
}
#[derive(Default)]
struct ToolCallPrinterScope {
input_json: String,
}
impl Handler<ToolUseBlockKind> for ToolCallPrinter {
type Scope = ToolCallPrinterScope;
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
match event {
ToolUseBlockEvent::Start(start) => {
scope.input_json.clear();
self.call_names
.lock()
.unwrap()
.insert(start.id.clone(), start.name.clone());
println!("\n🔧 Calling tool: {}", start.name);
}
ToolUseBlockEvent::InputJsonDelta(json) => {
scope.input_json.push_str(json);
}
ToolUseBlockEvent::Stop(_) => {
if scope.input_json.is_empty() {
println!(" Args: {{}}");
} else {
println!(" Args: {}", scope.input_json);
}
scope.input_json.clear();
}
}
}
}
/// Hook that displays tool execution results
struct ToolResultPrinterHook {
call_names: Arc<Mutex<HashMap<String, String>>>,
}
impl ToolResultPrinterHook {
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
Self { call_names }
}
}
#[async_trait]
impl Hook<PostToolCall> for ToolResultPrinterHook {
async fn call(&self, ctx: &mut PostToolCallContext) -> Result<PostToolCallResult, HookError> {
let name = self
.call_names
.lock()
.unwrap()
.remove(&ctx.result.tool_use_id)
.unwrap_or_else(|| ctx.result.tool_use_id.clone());
if ctx.result.is_error {
println!(" Result ({}): ❌ {}", name, ctx.result.content);
} else {
println!(" Result ({}): ✅ {}", name, ctx.result.content);
}
Ok(PostToolCallResult::Continue)
}
}
// =============================================================================
// Client Creation
// =============================================================================
/// Get API key based on provider
fn get_api_key(args: &Args) -> Result<String, String> {
// CLI argument API key takes precedence
if let Some(ref key) = args.api_key {
return Ok(key.clone());
}
// Check environment variable based on provider
if let Some(env_var) = args.provider.env_var_name() {
std::env::var(env_var).map_err(|_| {
format!(
"API key required. Set {} environment variable or use --api-key",
env_var
)
})
} else {
// Ollama etc. don't need a key
Ok(String::new())
}
}
/// Create client based on provider
fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
let model = args
.model
.clone()
.unwrap_or_else(|| args.provider.default_model().to_string());
let api_key = get_api_key(args)?;
match args.provider {
Provider::Anthropic => {
let client = AnthropicClient::new(&api_key, &model);
Ok(Box::new(client))
}
Provider::Gemini => {
let client = GeminiClient::new(&api_key, &model);
Ok(Box::new(client))
}
Provider::Openai => {
let client = OpenAIClient::new(&api_key, &model);
Ok(Box::new(client))
}
Provider::Ollama => {
let client = OllamaClient::new(&model);
Ok(Box::new(client))
}
}
}
// =============================================================================
// Main
// =============================================================================
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load .env file
dotenv::dotenv().ok();
// Initialize logging
// Use RUST_LOG=debug cargo run --example worker_cli ... for detailed logs
// Default is warn level, can be overridden with RUST_LOG environment variable
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.init();
// Parse CLI arguments
let args = Args::parse();
info!(
provider = ?args.provider,
model = ?args.model,
"Starting worker CLI"
);
// Interactive mode or one-shot mode
let is_interactive = args.prompt.is_none();
// Model name (for display)
let model_name = args
.model
.clone()
.unwrap_or_else(|| args.provider.default_model().to_string());
if is_interactive {
let title = format!("Worker CLI - {}", args.provider.display_name());
let border_len = title.len() + 6;
println!("{}", "".repeat(border_len));
println!("{}", title);
println!("{}", "".repeat(border_len));
println!();
println!("Provider: {}", args.provider.display_name());
println!("Model: {}", model_name);
if let Some(ref system) = args.system {
println!("System: {}", system);
}
if args.no_tools {
println!("Tools: disabled");
} else {
println!("Tools:");
println!(" • get_current_time - Get the current timestamp");
println!(" • calculate - Perform arithmetic (add, subtract, multiply, divide)");
}
println!();
println!("Type 'quit' or 'exit' to end the session.");
println!("─────────────────────────────────────────────────");
}
// Create client
let client = match create_client(&args) {
Ok(c) => c,
Err(e) => {
eprintln!("❌ Error: {}", e);
std::process::exit(1);
}
};
// Create Worker
let mut worker = Worker::new(client);
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
// Set system prompt
if let Some(ref system_prompt) = args.system {
worker.set_system_prompt(system_prompt);
}
// Register tools (unless --no-tools)
if !args.no_tools {
let app = AppContext;
worker
.register_tool(app.get_current_time_definition())
.unwrap();
worker.register_tool(app.calculate_definition()).unwrap();
}
// Register streaming display handlers
worker
.timeline_mut()
.on_text_block(StreamingPrinter::new())
.on_tool_use_block(ToolCallPrinter::new(tool_call_names.clone()));
worker.add_post_tool_call_hook(ToolResultPrinterHook::new(tool_call_names));
// One-shot mode
if let Some(prompt) = args.prompt {
match worker.run(&prompt).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
// Interactive loop
loop {
print!("\n👤 You: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
continue;
}
if input == "quit" || input == "exit" {
println!("\n👋 Goodbye!");
break;
}
// Run Worker (Worker manages history)
match worker.run(input).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
}
}
}
Ok(())
}

View File

@ -0,0 +1,446 @@
//! Public event types for Worker layer
//!
//! Event representation exposed to external users.
use serde::{Deserialize, Serialize};
// =============================================================================
// Core Event Types (from llm_client layer)
// =============================================================================
/// Streaming events from LLM
///
/// Responses from each LLM provider are processed uniformly
/// as a stream of `Event`.
///
/// # Event Types
///
/// - **Meta events**: `Ping`, `Usage`, `Status`, `Error`
/// - **Block events**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # Block Lifecycle
///
/// Text and tool calls have events in the order of
/// `BlockStart` → `BlockDelta`(multiple) → `BlockStop`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// Heartbeat
Ping(PingEvent),
/// Token usage
Usage(UsageEvent),
/// Stream status change
Status(StatusEvent),
/// Error occurred
Error(ErrorEvent),
/// Block start (text, tool use, etc.)
BlockStart(BlockStart),
/// Block delta data
BlockDelta(BlockDelta),
/// Block normal end
BlockStop(BlockStop),
/// Block abort
BlockAbort(BlockAbort),
}
// =============================================================================
// Meta Events
// =============================================================================
/// Ping event (heartbeat)
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// Usage event
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// Input token count
pub input_tokens: Option<u64>,
/// Output token count
pub output_tokens: Option<u64>,
/// Total token count
pub total_tokens: Option<u64>,
/// Cache read token count
pub cache_read_input_tokens: Option<u64>,
/// Cache creation token count
pub cache_creation_input_tokens: Option<u64>,
}
/// Status event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// Response status
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// Stream started
Started,
/// Completed normally
Completed,
/// Cancelled
Cancelled,
/// Error occurred
Failed,
}
/// Error event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Block Types
// =============================================================================
/// Block type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// Text generation
Text,
/// Thinking (Claude Extended Thinking, etc.)
Thinking,
/// Tool call
ToolUse,
/// Tool result
ToolResult,
}
/// Block start event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Block-specific metadata
pub metadata: BlockMetadata,
}
impl BlockStart {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Block metadata
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
/// Block delta event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// Block index
pub index: usize,
/// Delta content
pub delta: DeltaContent,
}
/// Delta content
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// Text delta
Text(String),
/// Thinking delta
Thinking(String),
/// JSON substring of tool arguments
InputJson(String),
}
impl DeltaContent {
/// Get block type of the delta
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
DeltaContent::Thinking(_) => BlockType::Thinking,
DeltaContent::InputJson(_) => BlockType::ToolUse,
}
}
}
/// Block stop event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Stop reason
pub stop_reason: Option<StopReason>,
}
impl BlockStop {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Block abort event
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// Block index
pub index: usize,
/// Block type
pub block_type: BlockType,
/// Abort reason
pub reason: String,
}
impl BlockAbort {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// Stop reason
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// Natural end
EndTurn,
/// Max tokens reached
MaxTokens,
/// Stop sequence reached
StopSequence,
/// Tool use
ToolUse,
}
// =============================================================================
// Builder / Factory helpers
// =============================================================================
impl Event {
/// Create text block start event
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::Text,
metadata: BlockMetadata::Text,
})
}
/// Create text delta event
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::Text(text.into()),
})
}
/// Create text block stop event
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
})
}
/// Create tool use block start event
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: id.into(),
name: name.into(),
},
})
}
/// Create tool input delta event
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::InputJson(json.into()),
})
}
/// Create tool use block stop event
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::ToolUse,
stop_reason: Some(StopReason::ToolUse),
})
}
/// Create usage event
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
total_tokens: Some(input_tokens + output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
/// Create ping event
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
}
// =============================================================================
// Conversions: timeline::event -> worker::event
// =============================================================================
impl From<crate::timeline::event::ResponseStatus> for ResponseStatus {
fn from(value: crate::timeline::event::ResponseStatus) -> Self {
match value {
crate::timeline::event::ResponseStatus::Started => ResponseStatus::Started,
crate::timeline::event::ResponseStatus::Completed => ResponseStatus::Completed,
crate::timeline::event::ResponseStatus::Cancelled => ResponseStatus::Cancelled,
crate::timeline::event::ResponseStatus::Failed => ResponseStatus::Failed,
}
}
}
impl From<crate::timeline::event::BlockType> for BlockType {
fn from(value: crate::timeline::event::BlockType) -> Self {
match value {
crate::timeline::event::BlockType::Text => BlockType::Text,
crate::timeline::event::BlockType::Thinking => BlockType::Thinking,
crate::timeline::event::BlockType::ToolUse => BlockType::ToolUse,
crate::timeline::event::BlockType::ToolResult => BlockType::ToolResult,
}
}
}
impl From<crate::timeline::event::BlockMetadata> for BlockMetadata {
fn from(value: crate::timeline::event::BlockMetadata) -> Self {
match value {
crate::timeline::event::BlockMetadata::Text => BlockMetadata::Text,
crate::timeline::event::BlockMetadata::Thinking => BlockMetadata::Thinking,
crate::timeline::event::BlockMetadata::ToolUse { id, name } => {
BlockMetadata::ToolUse { id, name }
}
crate::timeline::event::BlockMetadata::ToolResult { tool_use_id } => {
BlockMetadata::ToolResult { tool_use_id }
}
}
}
}
impl From<crate::timeline::event::DeltaContent> for DeltaContent {
fn from(value: crate::timeline::event::DeltaContent) -> Self {
match value {
crate::timeline::event::DeltaContent::Text(text) => DeltaContent::Text(text),
crate::timeline::event::DeltaContent::Thinking(text) => DeltaContent::Thinking(text),
crate::timeline::event::DeltaContent::InputJson(json) => DeltaContent::InputJson(json),
}
}
}
impl From<crate::timeline::event::StopReason> for StopReason {
fn from(value: crate::timeline::event::StopReason) -> Self {
match value {
crate::timeline::event::StopReason::EndTurn => StopReason::EndTurn,
crate::timeline::event::StopReason::MaxTokens => StopReason::MaxTokens,
crate::timeline::event::StopReason::StopSequence => StopReason::StopSequence,
crate::timeline::event::StopReason::ToolUse => StopReason::ToolUse,
}
}
}
impl From<crate::timeline::event::PingEvent> for PingEvent {
fn from(value: crate::timeline::event::PingEvent) -> Self {
PingEvent {
timestamp: value.timestamp,
}
}
}
impl From<crate::timeline::event::UsageEvent> for UsageEvent {
fn from(value: crate::timeline::event::UsageEvent) -> Self {
UsageEvent {
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
total_tokens: value.total_tokens,
cache_read_input_tokens: value.cache_read_input_tokens,
cache_creation_input_tokens: value.cache_creation_input_tokens,
}
}
}
impl From<crate::timeline::event::StatusEvent> for StatusEvent {
fn from(value: crate::timeline::event::StatusEvent) -> Self {
StatusEvent {
status: value.status.into(),
}
}
}
impl From<crate::timeline::event::ErrorEvent> for ErrorEvent {
fn from(value: crate::timeline::event::ErrorEvent) -> Self {
ErrorEvent {
code: value.code,
message: value.message,
}
}
}
impl From<crate::timeline::event::BlockStart> for BlockStart {
fn from(value: crate::timeline::event::BlockStart) -> Self {
BlockStart {
index: value.index,
block_type: value.block_type.into(),
metadata: value.metadata.into(),
}
}
}
impl From<crate::timeline::event::BlockDelta> for BlockDelta {
fn from(value: crate::timeline::event::BlockDelta) -> Self {
BlockDelta {
index: value.index,
delta: value.delta.into(),
}
}
}
impl From<crate::timeline::event::BlockStop> for BlockStop {
fn from(value: crate::timeline::event::BlockStop) -> Self {
BlockStop {
index: value.index,
block_type: value.block_type.into(),
stop_reason: value.stop_reason.map(Into::into),
}
}
}
impl From<crate::timeline::event::BlockAbort> for BlockAbort {
fn from(value: crate::timeline::event::BlockAbort) -> Self {
BlockAbort {
index: value.index,
block_type: value.block_type.into(),
reason: value.reason,
}
}
}
impl From<crate::timeline::event::Event> for Event {
fn from(value: crate::timeline::event::Event) -> Self {
match value {
crate::timeline::event::Event::Ping(p) => Event::Ping(p.into()),
crate::timeline::event::Event::Usage(u) => Event::Usage(u.into()),
crate::timeline::event::Event::Status(s) => Event::Status(s.into()),
crate::timeline::event::Event::Error(e) => Event::Error(e.into()),
crate::timeline::event::Event::BlockStart(s) => Event::BlockStart(s.into()),
crate::timeline::event::Event::BlockDelta(d) => Event::BlockDelta(d.into()),
crate::timeline::event::Event::BlockStop(s) => Event::BlockStop(s.into()),
crate::timeline::event::Event::BlockAbort(a) => Event::BlockAbort(a.into()),
}
}
}

View File

@ -0,0 +1,174 @@
//! Handler/Kind Types
//!
//! Traits for processing events in the Timeline layer.
//! By implementing custom handlers and registering them with Timeline,
//! you can receive stream events.
use crate::timeline::event::*;
// =============================================================================
// Kind Trait
// =============================================================================
/// Marker trait defining event types
///
/// Each Kind specifies its corresponding event type.
/// Handlers are implemented for this Kind, and multiple Handlers
/// with different Scope types can be registered for the same Kind.
pub trait Kind {
/// Event type corresponding to this Kind
type Event;
}
// =============================================================================
// Handler Trait
// =============================================================================
/// Handler trait for processing events
///
/// Defines event processing for a specific `Kind`.
/// `Scope` is state held during the block's lifecycle.
///
/// # Examples
///
/// ```ignore
/// use llm_worker::timeline::{Handler, TextBlockEvent, TextBlockKind};
///
/// struct TextCollector {
/// texts: Vec<String>,
/// }
///
/// impl Handler<TextBlockKind> for TextCollector {
/// type Scope = String; // Buffer per block
///
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
/// match event {
/// TextBlockEvent::Delta(text) => buffer.push_str(text),
/// TextBlockEvent::Stop(_) => {
/// self.texts.push(std::mem::take(buffer));
/// }
/// _ => {}
/// }
/// }
/// }
/// ```
pub trait Handler<K: Kind> {
/// Handler-specific scope type
///
/// Generated with `Default::default()` at block start,
/// and destroyed at block end.
type Scope: Default;
/// Process the event
fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event);
}
// =============================================================================
// Meta Kind Definitions
// =============================================================================
/// Usage Kind - for usage events
pub struct UsageKind;
impl Kind for UsageKind {
type Event = UsageEvent;
}
/// Ping Kind - for ping events
pub struct PingKind;
impl Kind for PingKind {
type Event = PingEvent;
}
/// Status Kind - for status events
pub struct StatusKind;
impl Kind for StatusKind {
type Event = StatusEvent;
}
/// Error Kind - for error events
pub struct ErrorKind;
impl Kind for ErrorKind {
type Event = ErrorEvent;
}
// =============================================================================
// Block Kind Definitions
// =============================================================================
/// TextBlock Kind - for text blocks
pub struct TextBlockKind;
impl Kind for TextBlockKind {
type Event = TextBlockEvent;
}
/// Text block events
#[derive(Debug, Clone, PartialEq)]
pub enum TextBlockEvent {
Start(TextBlockStart),
Delta(String),
Stop(TextBlockStop),
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextBlockStart {
pub index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextBlockStop {
pub index: usize,
pub stop_reason: Option<StopReason>,
}
/// ThinkingBlock Kind - for thinking blocks
pub struct ThinkingBlockKind;
impl Kind for ThinkingBlockKind {
type Event = ThinkingBlockEvent;
}
/// Thinking block events
#[derive(Debug, Clone, PartialEq)]
pub enum ThinkingBlockEvent {
Start(ThinkingBlockStart),
Delta(String),
Stop(ThinkingBlockStop),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThinkingBlockStart {
pub index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThinkingBlockStop {
pub index: usize,
}
/// ToolUseBlock Kind - for tool use blocks
pub struct ToolUseBlockKind;
impl Kind for ToolUseBlockKind {
type Event = ToolUseBlockEvent;
}
/// Tool use block events
#[derive(Debug, Clone, PartialEq)]
pub enum ToolUseBlockEvent {
Start(ToolUseBlockStart),
/// JSON substring of tool arguments
InputJsonDelta(String),
Stop(ToolUseBlockStop),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolUseBlockStart {
pub index: usize,
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolUseBlockStop {
pub index: usize,
pub id: String,
pub name: String,
}

View File

@ -0,0 +1,310 @@
//! Hook-related type definitions
//!
//! Types used for turn control and intervention in the Worker layer
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
// =============================================================================
// Hook Event Kinds
// =============================================================================
pub trait HookEventKind: Send + Sync + 'static {
type Input;
type Output;
}
pub struct OnPromptSubmit;
pub struct PreLlmRequest;
pub struct PreToolCall;
pub struct PostToolCall;
pub struct OnTurnEnd;
pub struct OnAbort;
pub struct OnTextDelta;
pub struct OnToolCallDelta;
pub struct OnStreamChunk;
pub struct OnStreamComplete;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OnPromptSubmitResult {
Continue,
Cancel(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreLlmRequestResult {
Continue,
Cancel(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreToolCallResult {
Continue,
Skip,
Abort(String),
Pause,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PostToolCallResult {
Continue,
Abort(String),
}
#[derive(Debug, Clone)]
pub enum OnTurnEndResult {
Finish,
ContinueWithMessages(Vec<crate::Item>),
Paused,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamHookResult {
Continue,
Abort(String),
Pause,
}
use std::sync::Arc;
use crate::tool::{Tool, ToolMeta};
/// Input context for PreToolCall
pub struct ToolCallContext {
/// Tool call information (modifiable)
pub call: ToolCall,
/// Tool meta information (immutable)
pub meta: ToolMeta,
/// Tool instance (for state access)
pub tool: Arc<dyn Tool>,
}
/// Input context for PostToolCall
pub struct PostToolCallContext {
/// Tool call information
pub call: ToolCall,
/// Tool execution result (modifiable)
pub result: ToolResult,
/// Tool meta information (immutable)
pub meta: ToolMeta,
/// Tool instance (for state access)
pub tool: Arc<dyn Tool>,
}
/// Input context for OnTextDelta
#[derive(Debug, Clone)]
pub struct TextDeltaContext {
/// Block index
pub index: usize,
/// Text delta content
pub delta: String,
}
/// Input context for OnToolCallDelta
#[derive(Debug, Clone)]
pub struct ToolCallDeltaContext {
/// Block index
pub index: usize,
/// Partial JSON fragment
pub delta_json_fragment: String,
}
/// Input context for OnStreamChunk
#[derive(Debug, Clone)]
pub struct StreamChunkContext {
/// Public worker-level event
pub event: crate::event::Event,
}
/// Input context for OnStreamComplete
#[derive(Debug, Clone)]
pub struct StreamCompleteContext {
/// Current turn number
pub turn: usize,
/// Number of streamed events in this request
pub event_count: usize,
}
impl HookEventKind for OnPromptSubmit {
type Input = crate::Item;
type Output = OnPromptSubmitResult;
}
impl HookEventKind for PreLlmRequest {
type Input = Vec<crate::Item>;
type Output = PreLlmRequestResult;
}
impl HookEventKind for PreToolCall {
type Input = ToolCallContext;
type Output = PreToolCallResult;
}
impl HookEventKind for PostToolCall {
type Input = PostToolCallContext;
type Output = PostToolCallResult;
}
impl HookEventKind for OnTurnEnd {
type Input = Vec<crate::Item>;
type Output = OnTurnEndResult;
}
impl HookEventKind for OnAbort {
type Input = String;
type Output = ();
}
impl HookEventKind for OnTextDelta {
type Input = TextDeltaContext;
type Output = StreamHookResult;
}
impl HookEventKind for OnToolCallDelta {
type Input = ToolCallDeltaContext;
type Output = StreamHookResult;
}
impl HookEventKind for OnStreamChunk {
type Input = StreamChunkContext;
type Output = StreamHookResult;
}
impl HookEventKind for OnStreamComplete {
type Input = StreamCompleteContext;
type Output = StreamHookResult;
}
// =============================================================================
// Tool Call / Result Types
// =============================================================================
/// Tool call information
///
/// Represents a ToolUse block from LLM, modifiable in Hook processing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
/// Tool call ID (used for linking with response)
pub id: String,
/// Tool name
pub name: String,
/// Input arguments (JSON)
pub input: Value,
}
/// Tool execution result
///
/// Represents the result after tool execution, modifiable in Hook processing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
/// Corresponding tool call ID
pub tool_use_id: String,
/// Result content
pub content: String,
/// Whether this is an error
#[serde(default)]
pub is_error: bool,
}
impl ToolResult {
/// Create a success result
pub fn success(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: false,
}
}
/// Create an error result
pub fn error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: true,
}
}
}
// =============================================================================
// Hook Error
// =============================================================================
/// Hook error
#[derive(Debug, Error)]
pub enum HookError {
/// Processing was aborted
#[error("Aborted: {0}")]
Aborted(String),
/// Internal error
#[error("Hook error: {0}")]
Internal(String),
}
// =============================================================================
// Hook Trait
// =============================================================================
/// Trait for handling Hook events
///
/// Each event type has a different return type, constrained via `HookEventKind`.
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &mut E::Input) -> Result<E::Output, HookError>;
}
// =============================================================================
// Hook Registry
// =============================================================================
/// Registry holding all Hooks
///
/// Used internally by Worker to manage all Hook types.
pub struct HookRegistry {
/// on_prompt_submit Hook
pub(crate) on_prompt_submit: Vec<Box<dyn Hook<OnPromptSubmit>>>,
/// pre_llm_request Hook
pub(crate) pre_llm_request: Vec<Box<dyn Hook<PreLlmRequest>>>,
/// pre_tool_call Hook
pub(crate) pre_tool_call: Vec<Box<dyn Hook<PreToolCall>>>,
/// post_tool_call Hook
pub(crate) post_tool_call: Vec<Box<dyn Hook<PostToolCall>>>,
/// on_turn_end Hook
pub(crate) on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
/// on_abort Hook
pub(crate) on_abort: Vec<Box<dyn Hook<OnAbort>>>,
/// on_text_delta Hook
pub(crate) on_text_delta: Vec<Box<dyn Hook<OnTextDelta>>>,
/// on_tool_call_delta Hook
pub(crate) on_tool_call_delta: Vec<Box<dyn Hook<OnToolCallDelta>>>,
/// on_stream_chunk Hook
pub(crate) on_stream_chunk: Vec<Box<dyn Hook<OnStreamChunk>>>,
/// on_stream_complete Hook
pub(crate) on_stream_complete: Vec<Box<dyn Hook<OnStreamComplete>>>,
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
impl HookRegistry {
/// Create an empty HookRegistry
pub fn new() -> Self {
Self {
on_prompt_submit: Vec::new(),
pre_llm_request: Vec::new(),
pre_tool_call: Vec::new(),
post_tool_call: Vec::new(),
on_turn_end: Vec::new(),
on_abort: Vec::new(),
on_text_delta: Vec::new(),
on_tool_call_delta: Vec::new(),
on_stream_chunk: Vec::new(),
on_stream_complete: Vec::new(),
}
}
}

View File

@ -0,0 +1,52 @@
//! llm-worker - LLM Worker Library
//!
//! Provides components for managing interactions with LLMs.
//!
//! # Main Components
//!
//! - [`Worker`] - Central component for managing LLM interactions
//! - [`tool::Tool`] - Tools that can be invoked by the LLM
//! - [`hook::Hook`] - Hooks for intercepting turn progression
//! - [`subscriber::WorkerSubscriber`] - Subscribing to streaming events
//!
//! # Quick Start
//!
//! ```ignore
//! use llm_worker::{Worker, Item};
//!
//! // Create a Worker
//! let mut worker = Worker::new(client)
//! .system_prompt("You are a helpful assistant.");
//!
//! // Register tools (optional)
//! // worker.register_tool(my_tool_definition)?;
//!
//! // Run the interaction
//! let history = worker.run("Hello!").await?;
//! ```
//!
//! # Cache Protection
//!
//! To maximize KV cache hit rate, transition to the locked state
//! with [`Worker::lock()`] before execution.
//!
//! ```ignore
//! let mut locked = worker.lock();
//! locked.run("user input").await?;
//! ```
mod handler;
mod message;
mod worker;
pub mod event;
pub mod hook;
pub mod llm_client;
pub mod state;
pub mod subscriber;
pub mod timeline;
pub mod tool;
pub mod tool_server;
pub use message::{ContentPart, Item, Message, Role};
pub use worker::{ToolRegistryError, Worker, WorkerConfig, WorkerError, WorkerResult};

View File

@ -0,0 +1,86 @@
//! LLMクライアント共通trait定義
use std::pin::Pin;
use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
use async_trait::async_trait;
use futures::Stream;
/// 設定に関する警告
///
/// プロバイダがサポートしていない設定を使用した場合に返される。
#[derive(Debug, Clone)]
pub struct ConfigWarning {
/// 設定オプション名
pub option_name: &'static str,
/// 警告メッセージ
pub message: String,
}
impl ConfigWarning {
/// 新しい警告を作成
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
Self {
option_name,
message: format!(
"'{}' is not supported by {} and will be ignored",
option_name, provider_name
),
}
}
}
impl std::fmt::Display for ConfigWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.option_name, self.message)
}
}
/// LLMクライアントのtrait
///
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
#[async_trait]
pub trait LlmClient: Send + Sync {
/// ストリーミングリクエストを送信し、Eventストリームを返す
///
/// # Arguments
/// * `request` - リクエスト情報
///
/// # Returns
/// * `Ok(Stream)` - イベントストリーム
/// * `Err(ClientError)` - エラー
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>;
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
///
/// # Arguments
/// * `config` - バリデーション対象の設定
///
/// # Returns
/// サポートされていない設定に対する警告のリスト
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
// デフォルト実装: 全ての設定をサポート
let _ = config;
Vec::new()
}
}
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
///
/// これにより、動的ディスパッチを使用するクライアントも `Worker` で利用可能になる。
#[async_trait]
impl LlmClient for Box<dyn LlmClient> {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
(**self).stream(request).await
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
(**self).validate_config(config)
}
}

View File

@ -0,0 +1,69 @@
//! LLMクライアントエラー型
use std::fmt;
/// LLMクライアントのエラー
#[derive(Debug)]
pub enum ClientError {
/// HTTPリクエストエラー
Http(reqwest::Error),
/// JSONパースエラー
Json(serde_json::Error),
/// SSEパースエラー
Sse(String),
/// APIエラー (プロバイダからのエラーレスポンス)
Api {
status: Option<u16>,
code: Option<String>,
message: String,
},
/// 設定エラー
Config(String),
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Http(e) => write!(f, "HTTP error: {}", e),
ClientError::Json(e) => write!(f, "JSON parse error: {}", e),
ClientError::Sse(msg) => write!(f, "SSE parse error: {}", msg),
ClientError::Api {
status,
code,
message,
} => {
write!(f, "API error")?;
if let Some(s) = status {
write!(f, " (status: {})", s)?;
}
if let Some(c) = code {
write!(f, " [{}]", c)?;
}
write!(f, ": {}", message)
}
ClientError::Config(msg) => write!(f, "Config error: {}", msg),
}
}
}
impl std::error::Error for ClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ClientError::Http(e) => Some(e),
ClientError::Json(e) => Some(e),
_ => None,
}
}
}
impl From<reqwest::Error> for ClientError {
fn from(err: reqwest::Error) -> Self {
ClientError::Http(err)
}
}
impl From<serde_json::Error> for ClientError {
fn from(err: serde_json::Error) -> Self {
ClientError::Json(err)
}
}

View File

@ -0,0 +1,293 @@
//! LLMクライアント層のイベント型
//!
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
use serde::{Deserialize, Serialize};
// =============================================================================
// Core Event Types (from llm_client layer)
// =============================================================================
/// LLMからのストリーミングイベント
///
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
/// 統一的に処理されます。
///
/// # イベントの種類
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # ブロックのライフサイクル
///
/// テキストやツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
/// の順序でイベントが発生します。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// ハートビート
Ping(PingEvent),
/// トークン使用量
Usage(UsageEvent),
/// ストリームのステータス変化
Status(StatusEvent),
/// エラー発生
Error(ErrorEvent),
/// ブロック開始(テキスト、ツール使用等)
BlockStart(BlockStart),
/// ブロックの差分データ
BlockDelta(BlockDelta),
/// ブロック正常終了
BlockStop(BlockStop),
/// ブロック中断
BlockAbort(BlockAbort),
}
// =============================================================================
// Meta Events
// =============================================================================
/// Pingイベントハートビート
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// 使用量イベント
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 入力トークン数
pub input_tokens: Option<u64>,
/// 出力トークン数
pub output_tokens: Option<u64>,
/// 合計トークン数
pub total_tokens: Option<u64>,
/// キャッシュ読み込みトークン数
pub cache_read_input_tokens: Option<u64>,
/// キャッシュ作成トークン数
pub cache_creation_input_tokens: Option<u64>,
}
/// ステータスイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// レスポンスステータス
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// ストリーム開始
Started,
/// 正常完了
Completed,
/// キャンセルされた
Cancelled,
/// エラー発生
Failed,
}
/// エラーイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Block Types
// =============================================================================
/// ブロックの種別
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// テキスト生成
Text,
/// 思考 (Claude Extended Thinking等)
Thinking,
/// ツール呼び出し
ToolUse,
/// ツール結果
ToolResult,
}
/// ブロック開始イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// ブロック固有のメタデータ
pub metadata: BlockMetadata,
}
impl BlockStart {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロックのメタデータ
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
/// ブロックデルタイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// ブロックのインデックス
pub index: usize,
/// デルタの内容
pub delta: DeltaContent,
}
/// デルタの内容
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// テキストデルタ
Text(String),
/// 思考デルタ
Thinking(String),
/// ツール引数のJSON部分文字列
InputJson(String),
}
impl DeltaContent {
/// デルタのブロック種別を取得
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
DeltaContent::Thinking(_) => BlockType::Thinking,
DeltaContent::InputJson(_) => BlockType::ToolUse,
}
}
}
/// ブロック停止イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 停止理由
pub stop_reason: Option<StopReason>,
}
impl BlockStop {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロック中断イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 中断理由
pub reason: String,
}
impl BlockAbort {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// 停止理由
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// 自然終了
EndTurn,
/// 最大トークン数到達
MaxTokens,
/// ストップシーケンス到達
StopSequence,
/// ツール使用
ToolUse,
}
// =============================================================================
// Builder / Factory helpers
// =============================================================================
impl Event {
/// テキストブロック開始イベントを作成
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::Text,
metadata: BlockMetadata::Text,
})
}
/// テキストデルタイベントを作成
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::Text(text.into()),
})
}
/// テキストブロック停止イベントを作成
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
})
}
/// ツール使用ブロック開始イベントを作成
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: id.into(),
name: name.into(),
},
})
}
/// ツール引数デルタイベントを作成
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::InputJson(json.into()),
})
}
/// ツール使用ブロック停止イベントを作成
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::ToolUse,
stop_reason: Some(StopReason::ToolUse),
})
}
/// 使用量イベントを作成
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
total_tokens: Some(input_tokens + output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
/// Pingイベントを作成
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
}

View File

@ -0,0 +1,30 @@
//! LLMクライアント層
//!
//! 各LLMプロバイダと通信し、統一された[`Event`]
//! ストリームを出力します。
//!
//! # サポートするプロバイダ
//!
//! - Anthropic (Claude)
//! - OpenAI (GPT-4, etc.)
//! - Google (Gemini)
//! - Ollama (ローカルLLM)
//!
//! # アーキテクチャ
//!
//! - [`LlmClient`] - プロバイダ共通のtrait
//! - `providers`: プロバイダ固有のクライアント実装
//! - `scheme`: APIスキーマリクエスト/レスポンス変換)
pub mod client;
pub mod error;
pub mod event;
pub mod types;
pub mod providers;
pub mod scheme;
pub use client::*;
pub use error::*;
pub use event::*;
pub use types::*;

View File

@ -0,0 +1,201 @@
//! Anthropic プロバイダ実装
//!
//! Anthropic Messages APIと通信し、Eventストリームを出力
use std::pin::Pin;
use crate::llm_client::{
ClientError, LlmClient, Request, event::Event, scheme::anthropic::AnthropicScheme,
};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt, future::ready};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
/// Anthropic クライアント
pub struct AnthropicClient {
/// HTTPクライアント
http_client: reqwest::Client,
/// APIキー
api_key: String,
/// モデル名
model: String,
/// スキーマ
scheme: AnthropicScheme,
/// ベースURL
base_url: String,
}
impl AnthropicClient {
/// 新しいAnthropicクライアントを作成
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
http_client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
scheme: AnthropicScheme::default(),
base_url: "https://api.anthropic.com".to_string(),
}
}
/// カスタムHTTPクライアントを設定
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
}
/// スキーマを設定
pub fn with_scheme(mut self, scheme: AnthropicScheme) -> Self {
self.scheme = scheme;
self
}
/// ベースURLを設定
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// リクエストヘッダーを構築
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key)
.map_err(|e| ClientError::Config(format!("Invalid API key: {}", e)))?,
);
headers.insert(
"anthropic-version",
HeaderValue::from_str(&self.scheme.api_version)
.map_err(|e| ClientError::Config(format!("Invalid API version: {}", e)))?,
);
// 細粒度ツールストリーミングを有効にする場合
if self.scheme.fine_grained_tool_streaming {
headers.insert(
"anthropic-beta",
HeaderValue::from_static("fine-grained-tool-streaming-2025-05-14"),
);
}
Ok(headers)
}
}
#[async_trait]
impl LlmClient for AnthropicClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let url = format!("{}/v1/messages", self.base_url);
let headers = self.build_headers()?;
let body = self.scheme.build_request(&self.model, &request);
let response = self
.http_client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await?;
// エラーレスポンスをチェック
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
// JSONでエラーをパースしてみる
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
return Err(ClientError::Api {
status: Some(status),
code,
message,
});
}
return Err(ClientError::Api {
status: Some(status),
code: None,
message: text,
});
}
// SSEストリームを構築
let scheme = self.scheme.clone();
let byte_stream = response
.bytes_stream()
.map_err(|e| std::io::Error::other(e));
let event_stream = byte_stream.eventsource();
// AnthropicはBlockStopイベントに正しいblock_typeを含まないため、
// クライアント側で状態を追跡して補完する
let mut current_block_type = None;
let stream = event_stream.filter_map(move |result| {
ready(match result {
Ok(event) => {
// SSEイベントをパース
match scheme.parse_event(&event.event, &event.data) {
Ok(Some(mut evt)) => {
// ブロックタイプの追跡と修正
match &evt {
Event::BlockStart(start) => {
current_block_type = Some(start.block_type);
}
Event::BlockStop(stop) => {
if let Some(block_type) = current_block_type.take() {
// 正しいブロックタイプで上書き
// (Event::BlockStopの中身を置換)
evt =
Event::BlockStop(crate::llm_client::event::BlockStop {
block_type,
..stop.clone()
});
}
}
_ => {}
}
Some(Ok(evt))
}
Ok(None) => None,
Err(e) => Some(Err(e)),
}
}
Err(e) => Some(Err(ClientError::Sse(e.to_string()))),
})
});
Ok(Box::pin(stream))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
assert_eq!(client.model, "claude-sonnet-4-20250514");
}
#[test]
fn test_build_headers() {
let client = AnthropicClient::new("test-key", "claude-sonnet-4-20250514");
let headers = client.build_headers().unwrap();
assert!(headers.contains_key("x-api-key"));
assert!(headers.contains_key("anthropic-version"));
assert!(headers.contains_key("anthropic-beta"));
}
}

View File

@ -0,0 +1,185 @@
//! Gemini プロバイダ実装
//!
//! Google Gemini APIと通信し、Eventストリームを出力
use std::pin::Pin;
use crate::llm_client::{
ClientError, LlmClient, Request, event::Event, scheme::gemini::GeminiScheme,
};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
/// Gemini クライアント
pub struct GeminiClient {
/// HTTPクライアント
http_client: reqwest::Client,
/// APIキー
api_key: String,
/// モデル名
model: String,
/// スキーマ
scheme: GeminiScheme,
/// ベースURL
base_url: String,
}
impl GeminiClient {
/// 新しいGeminiクライアントを作成
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
http_client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
scheme: GeminiScheme::default(),
base_url: "https://generativelanguage.googleapis.com".to_string(),
}
}
/// カスタムHTTPクライアントを設定
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
}
/// スキーマを設定
pub fn with_scheme(mut self, scheme: GeminiScheme) -> Self {
self.scheme = scheme;
self
}
/// ベースURLを設定
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// リクエストヘッダーを構築
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
}
#[async_trait]
impl LlmClient for GeminiClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
// URL構築: base_url/v1beta/models/{model}:streamGenerateContent?alt=sse&key={api_key}
let url = format!(
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
self.base_url, self.model, self.api_key
);
let headers = self.build_headers()?;
let body = self.scheme.build_request(&request);
let response = self
.http_client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await?;
// エラーレスポンスをチェック
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
// JSONでエラーをパースしてみる
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
// Gemini error format: { "error": { "code": xxx, "message": "...", "status": "..." } }
let error = json.get("error").unwrap_or(&json);
let code = error
.get("status")
.and_then(|v| v.as_str())
.map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
return Err(ClientError::Api {
status: Some(status),
code,
message,
});
}
return Err(ClientError::Api {
status: Some(status),
code: None,
message: text,
});
}
// SSEストリームを構築
let scheme = self.scheme.clone();
let byte_stream = response
.bytes_stream()
.map_err(|e| std::io::Error::other(e));
let event_stream = byte_stream.eventsource();
let stream = event_stream
.map(move |result| {
match result {
Ok(event) => {
// SSEイベントをパース
// Geminiは "data: {...}" 形式で送る
match scheme.parse_event(&event.data) {
Ok(Some(events)) => Ok(Some(events)),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
Err(e) => Err(ClientError::Sse(e.to_string())),
}
})
// flatten Option<Vec<Event>> stream to Stream<Event>
.map(|res| {
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
Ok(Some(events)) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
Ok(None) => Box::pin(futures::stream::empty()),
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
};
s
})
.flatten();
Ok(Box::pin(stream))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
assert_eq!(client.model, "gemini-2.0-flash");
}
#[test]
fn test_build_headers() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash");
let headers = client.build_headers().unwrap();
assert!(headers.contains_key("content-type"));
}
#[test]
fn test_custom_base_url() {
let client = GeminiClient::new("test-key", "gemini-2.0-flash")
.with_base_url("https://custom.api.example.com");
assert_eq!(client.base_url, "https://custom.api.example.com");
}
}

View File

@ -0,0 +1,8 @@
//! プロバイダ実装
//!
//! 各プロバイダ固有のHTTPクライアント実装
pub mod anthropic;
pub mod gemini;
pub mod ollama;
pub mod openai;

View File

@ -0,0 +1,62 @@
//! Ollama プロバイダ実装
//!
//! OllamaはOpenAI互換APIを提供するため、OpenAIクライアントと互換性がある。
//! デフォルトのベースURLと認証設定が異なる。
use std::pin::Pin;
use crate::llm_client::{
ClientError, LlmClient, Request, event::Event, providers::openai::OpenAIClient,
scheme::openai::OpenAIScheme,
};
use async_trait::async_trait;
use futures::Stream;
/// Ollama クライアント
///
/// 内部的にOpenAIClientを使用するラッパー、もしくはOpenAIClientと同様の実装を持つ。
/// ここではOpenAIClient構成をカスタマイズして提供する。
pub struct OllamaClient {
inner: OpenAIClient,
}
impl OllamaClient {
/// 新しいOllamaクライアントを作成
pub fn new(model: impl Into<String>) -> Self {
// Ollama usually runs on localhost:11434/v1
// API key is "ollama" or ignored
let base_url = "http://localhost:11434";
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
let client = OpenAIClient::new("ollama", model)
.with_base_url(base_url)
.with_scheme(scheme);
// Currently OpenAIScheme sets include_usage: true. Ollama supports checks?
// Assuming Ollama modern versions support usage.
Self { inner: client }
}
/// ベースURLを設定
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.inner = self.inner.with_base_url(url);
self
}
/// カスタムHTTPクライアントを設定
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.inner = self.inner.with_http_client(client);
self
}
}
#[async_trait]
impl LlmClient for OllamaClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.inner.stream(request).await
}
}

View File

@ -0,0 +1,212 @@
//! OpenAI プロバイダ実装
//!
//! OpenAI Chat Completions APIと通信し、Eventストリームを出力
use std::pin::Pin;
use crate::llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, event::Event,
scheme::openai::OpenAIScheme,
};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
/// OpenAI クライアント
pub struct OpenAIClient {
/// HTTPクライアント
http_client: reqwest::Client,
/// APIキー
api_key: String,
/// モデル名
model: String,
/// スキーマ
scheme: OpenAIScheme,
/// ベースURL
base_url: String,
}
impl OpenAIClient {
/// 新しいOpenAIクライアントを作成
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
http_client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
scheme: OpenAIScheme::default(),
base_url: "https://api.openai.com".to_string(),
}
}
/// カスタムHTTPクライアントを設定
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = client;
self
}
/// スキーマを設定
pub fn with_scheme(mut self, scheme: OpenAIScheme) -> Self {
self.scheme = scheme;
self
}
/// ベースURLを設定
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// リクエストヘッダーを構築
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let api_key_val = if self.api_key.is_empty() {
// For providers like Ollama, API key might be empty/dummy.
// But typical OpenAI requires it.
// We'll allow empty if user intends it, but usually it's checked.
HeaderValue::from_static("")
} else {
let mut val = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
.map_err(|e| ClientError::Config(format!("Invalid API key: {}", e)))?;
val.set_sensitive(true);
val
};
if !api_key_val.is_empty() {
headers.insert("Authorization", api_key_val);
}
Ok(headers)
}
}
#[async_trait]
impl LlmClient for OpenAIClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
// Construct the URL: base_url usually ends without slash, path starts with slash or vice versa.
// Standard OpenAI base is "https://api.openai.com". Endpoint is "/v1/chat/completions".
// If external base_url includes /v1, we should be careful.
// Let's assume defaults. If user provides "http://localhost:11434/v1", we append "/chat/completions".
// Or cleaner: user provides full base up to version?
// Anthropic client uses "{}/v1/messages".
// Let's stick to appending "/v1/chat/completions" if base is just host,
// OR assume base includes /v1 if user overrides it?
// Let's use robust joining or simple assumption matching Anthropic pattern:
// Default: https://api.openai.com -> https://api.openai.com/v1/chat/completions
// However, Ollama default is http://localhost:11434/v1/chat/completions if using OpenAI compact.
// If we configure base_url via `with_base_url`, it's flexible.
// Let's try to detect if /v1 is present or just append consistently.
// Ideally `base_url` should be the root passed to `new`.
let url = if self.base_url.ends_with("/v1") {
format!("{}/chat/completions", self.base_url)
} else if self.base_url.ends_with("/") {
format!("{}v1/chat/completions", self.base_url)
} else {
format!("{}/v1/chat/completions", self.base_url)
};
let headers = self.build_headers()?;
let body = self.scheme.build_request(&self.model, &request);
let response = self
.http_client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await?;
// エラーレスポンスをチェック
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
// JSONでエラーをパースしてみる
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
// OpenAI error format: { "error": { "message": "...", "type": "...", ... } }
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
return Err(ClientError::Api {
status: Some(status),
code,
message,
});
}
return Err(ClientError::Api {
status: Some(status),
code: None,
message: text,
});
}
// SSEストリームを構築
let scheme = self.scheme.clone();
let byte_stream = response
.bytes_stream()
.map_err(|e| std::io::Error::other(e));
let event_stream = byte_stream.eventsource();
let stream = event_stream
.map(move |result| {
match result {
Ok(event) => {
// SSEイベントをパース
// OpenAI stream events are "data: {...}"
// event.event is usually "message" (default) or empty.
// parse_event takes data string.
if event.data == "[DONE]" {
// End of stream handled inside parse_event usually returning None
Ok(None)
} else {
match scheme.parse_event(&event.data) {
Ok(Some(events)) => Ok(Some(events)),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
}
Err(e) => Err(ClientError::Sse(e.to_string())),
}
})
// flatten Option<Vec<Event>> stream to Stream<Event>
// map returns Result<Option<Vec<Event>>, Error>
// We want Stream<Item = Result<Event, Error>>
.map(|res| {
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
Ok(Some(events)) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
Ok(None) => Box::pin(futures::stream::empty()),
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
};
s
})
.flatten();
Ok(Box::pin(stream))
}
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// OpenAI does not support top_k
if config.top_k.is_some() {
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI"));
}
warnings
}
}

View File

@ -0,0 +1,373 @@
//! Anthropic SSEイベントパース
//!
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
use crate::llm_client::{
ClientError,
event::{
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
Event, PingEvent, ResponseStatus, StatusEvent, UsageEvent,
},
};
use serde::Deserialize;
use super::AnthropicScheme;
/// Anthropic SSEイベントタイプ
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnthropicEventType {
MessageStart,
ContentBlockStart,
ContentBlockDelta,
ContentBlockStop,
MessageDelta,
MessageStop,
Ping,
Error,
}
impl AnthropicEventType {
/// イベントタイプ文字列からパース
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"message_start" => Some(Self::MessageStart),
"content_block_start" => Some(Self::ContentBlockStart),
"content_block_delta" => Some(Self::ContentBlockDelta),
"content_block_stop" => Some(Self::ContentBlockStop),
"message_delta" => Some(Self::MessageDelta),
"message_stop" => Some(Self::MessageStop),
"ping" => Some(Self::Ping),
"error" => Some(Self::Error),
_ => None,
}
}
}
// ============================================================================
// SSEイベントのJSON構造
// ============================================================================
/// message_start イベント
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartEvent {
pub message: MessageStartMessage,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageStartMessage {
pub id: String,
pub model: String,
pub usage: Option<UsageData>,
}
/// content_block_start イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStartEvent {
pub index: usize,
pub content_block: ContentBlock,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
/// content_block_delta イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockDeltaEvent {
pub index: usize,
pub delta: DeltaBlock,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum DeltaBlock {
#[serde(rename = "text_delta")]
TextDelta { text: String },
#[serde(rename = "thinking_delta")]
ThinkingDelta { thinking: String },
#[serde(rename = "input_json_delta")]
InputJsonDelta { partial_json: String },
#[serde(rename = "signature_delta")]
SignatureDelta { signature: String },
}
/// content_block_stop イベント
#[derive(Debug, Deserialize)]
pub(crate) struct ContentBlockStopEvent {
pub index: usize,
}
/// message_delta イベント
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaEvent {
pub delta: MessageDeltaData,
pub usage: Option<UsageData>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct MessageDeltaData {
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
}
/// 使用量データ
#[derive(Debug, Deserialize)]
pub(crate) struct UsageData {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
/// エラーイベント
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEventData {
pub error: ErrorDetail,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorDetail {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
}
// ============================================================================
// イベント変換
// ============================================================================
impl AnthropicScheme {
/// SSEイベントをEvent型に変換
///
/// # Arguments
/// * `event_type` - SSEイベントタイプ
/// * `data` - イベントデータJSON文字列
///
/// # Returns
/// * `Ok(Some(Event))` - 変換成功
/// * `Ok(None)` - イベントを無視unknown event等
/// * `Err(ClientError)` - パースエラー
pub(crate) fn parse_event(
&self,
event_type: &str,
data: &str,
) -> Result<Option<Event>, ClientError> {
let Some(event_type) = AnthropicEventType::parse(event_type) else {
// Unknown event type, ignore
return Ok(None);
};
match event_type {
AnthropicEventType::MessageStart => {
let event: MessageStartEvent = serde_json::from_str(data)?;
// message_start時にUsageイベントがあれば出力
if let Some(usage) = event.message.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
// Statusイベントとして開始を通知
Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Started,
})))
}
AnthropicEventType::ContentBlockStart => {
let event: ContentBlockStartEvent = serde_json::from_str(data)?;
Ok(Some(self.convert_block_start(&event)))
}
AnthropicEventType::ContentBlockDelta => {
let event: ContentBlockDeltaEvent = serde_json::from_str(data)?;
Ok(self.convert_block_delta(&event))
}
AnthropicEventType::ContentBlockStop => {
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
Ok(Some(Event::BlockStop(BlockStop {
index: event.index,
block_type: BlockType::Text, // Timeline層で上書きされる
stop_reason: None,
})))
}
AnthropicEventType::MessageDelta => {
let event: MessageDeltaEvent = serde_json::from_str(data)?;
// Usage情報があれば出力
if let Some(usage) = event.usage {
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
}
Ok(None)
}
AnthropicEventType::MessageStop => Ok(Some(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}))),
AnthropicEventType::Ping => Ok(Some(Event::Ping(PingEvent { timestamp: None }))),
AnthropicEventType::Error => {
let event: ErrorEventData = serde_json::from_str(data)?;
Ok(Some(Event::Error(ErrorEvent {
code: Some(event.error.error_type),
message: event.error.message,
})))
}
}
}
fn convert_block_start(&self, event: &ContentBlockStartEvent) -> Event {
let (block_type, metadata) = match &event.content_block {
ContentBlock::Text { .. } => (BlockType::Text, BlockMetadata::Text),
ContentBlock::Thinking { .. } => (BlockType::Thinking, BlockMetadata::Thinking),
ContentBlock::ToolUse { id, name, .. } => (
BlockType::ToolUse,
BlockMetadata::ToolUse {
id: id.clone(),
name: name.clone(),
},
),
};
Event::BlockStart(BlockStart {
index: event.index,
block_type,
metadata,
})
}
fn convert_block_delta(&self, event: &ContentBlockDeltaEvent) -> Option<Event> {
let delta = match &event.delta {
DeltaBlock::TextDelta { text } => DeltaContent::Text(text.clone()),
DeltaBlock::ThinkingDelta { thinking } => DeltaContent::Thinking(thinking.clone()),
DeltaBlock::InputJsonDelta { partial_json } => {
DeltaContent::InputJson(partial_json.clone())
}
DeltaBlock::SignatureDelta { .. } => {
// signature_delta は無視
return None;
}
};
Some(Event::BlockDelta(BlockDelta {
index: event.index,
delta,
}))
}
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
let input = usage.input_tokens.unwrap_or(0);
let output = usage.output_tokens.unwrap_or(0);
UsageEvent {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: Some(input + output),
cache_read_input_tokens: usage.cache_read_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_message_start() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#;
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
match event {
Event::Usage(u) => {
assert_eq!(u.input_tokens, Some(10));
}
_ => panic!("Expected Usage event"),
}
}
#[test]
fn test_parse_content_block_start_text() {
let scheme = AnthropicScheme::new();
let data =
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
let event = scheme
.parse_event("content_block_start", data)
.unwrap()
.unwrap();
match event {
Event::BlockStart(s) => {
assert_eq!(s.index, 0);
assert_eq!(s.block_type, BlockType::Text);
}
_ => panic!("Expected BlockStart event"),
}
}
#[test]
fn test_parse_content_block_delta_text() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event = scheme
.parse_event("content_block_delta", data)
.unwrap()
.unwrap();
match event {
Event::BlockDelta(d) => {
assert_eq!(d.index, 0);
match d.delta {
DeltaContent::Text(t) => assert_eq!(t, "Hello"),
_ => panic!("Expected Text delta"),
}
}
_ => panic!("Expected BlockDelta event"),
}
}
#[test]
fn test_parse_tool_use_start() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_123","name":"get_weather","input":{}}}"#;
let event = scheme
.parse_event("content_block_start", data)
.unwrap()
.unwrap();
match event {
Event::BlockStart(s) => {
assert_eq!(s.block_type, BlockType::ToolUse);
match s.metadata {
BlockMetadata::ToolUse { id, name } => {
assert_eq!(id, "toolu_123");
assert_eq!(name, "get_weather");
}
_ => panic!("Expected ToolUse metadata"),
}
}
_ => panic!("Expected BlockStart event"),
}
}
#[test]
fn test_parse_ping() {
let scheme = AnthropicScheme::new();
let data = r#"{"type":"ping"}"#;
let event = scheme.parse_event("ping", data).unwrap().unwrap();
match event {
Event::Ping(_) => {}
_ => panic!("Expected Ping event"),
}
}
}

View File

@ -0,0 +1,40 @@
//! Anthropic Messages API スキーマ
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod events;
mod request;
/// Anthropicスキーマ
///
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
#[derive(Debug, Clone)]
pub struct AnthropicScheme {
/// APIバージョン
pub api_version: String,
/// 細粒度ツールストリーミングを有効にするか
pub fine_grained_tool_streaming: bool,
}
impl Default for AnthropicScheme {
fn default() -> Self {
Self {
api_version: "2023-06-01".to_string(),
fine_grained_tool_streaming: true,
}
}
}
impl AnthropicScheme {
/// 新しいスキーマを作成
pub fn new() -> Self {
Self::default()
}
/// 細粒度ツールストリーミングを有効/無効にする
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
self.fine_grained_tool_streaming = enabled;
self
}
}

View File

@ -0,0 +1,326 @@
//! Anthropic Request Builder
//!
//! Converts Open Responses native Item model to Anthropic Messages API format.
use serde::Serialize;
use crate::llm_client::{
Request,
types::{ContentPart, Item, Role, ToolDefinition},
};
use super::AnthropicScheme;
/// Anthropic API request body
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicRequest {
pub model: String,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
pub messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<AnthropicTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
pub stream: bool,
}
/// Anthropic message
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicMessage {
pub role: String,
pub content: AnthropicContent,
}
/// Anthropic content
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum AnthropicContent {
Text(String),
Parts(Vec<AnthropicContentPart>),
}
/// Anthropic content part
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub(crate) enum AnthropicContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
},
}
/// Anthropic tool definition
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
impl AnthropicScheme {
/// Build Anthropic request from Request
pub(crate) fn build_request(&self, model: &str, request: &Request) -> AnthropicRequest {
let messages = self.convert_items_to_messages(&request.items);
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
AnthropicRequest {
model: model.to_string(),
max_tokens: request.config.max_tokens.unwrap_or(4096),
system: request.system_prompt.clone(),
messages,
tools,
temperature: request.config.temperature,
top_p: request.config.top_p,
top_k: request.config.top_k,
stop_sequences: request.config.stop_sequences.clone(),
stream: true,
}
}
/// Convert Open Responses Items to Anthropic Messages
///
/// Anthropic uses a message-based model where:
/// - User messages have role "user"
/// - Assistant messages have role "assistant"
/// - Tool calls are content parts within assistant messages
/// - Tool results are content parts within user messages
fn convert_items_to_messages(&self, items: &[Item]) -> Vec<AnthropicMessage> {
let mut messages = Vec::new();
let mut pending_assistant_parts: Vec<AnthropicContentPart> = Vec::new();
let mut pending_user_parts: Vec<AnthropicContentPart> = Vec::new();
for item in items {
match item {
Item::Message { role, content, .. } => {
// Flush pending parts before a new message
self.flush_pending_parts(
&mut messages,
&mut pending_assistant_parts,
&mut pending_user_parts,
);
let anthropic_role = match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => continue, // Skip system role items
};
let parts: Vec<AnthropicContentPart> = content
.iter()
.map(|p| match p {
ContentPart::InputText { text } => {
AnthropicContentPart::Text { text: text.clone() }
}
ContentPart::OutputText { text } => {
AnthropicContentPart::Text { text: text.clone() }
}
ContentPart::Refusal { refusal } => AnthropicContentPart::Text {
text: refusal.clone(),
},
})
.collect();
if parts.len() == 1 {
if let AnthropicContentPart::Text { text } = &parts[0] {
messages.push(AnthropicMessage {
role: anthropic_role.to_string(),
content: AnthropicContent::Text(text.clone()),
});
} else {
messages.push(AnthropicMessage {
role: anthropic_role.to_string(),
content: AnthropicContent::Parts(parts),
});
}
} else {
messages.push(AnthropicMessage {
role: anthropic_role.to_string(),
content: AnthropicContent::Parts(parts),
});
}
}
Item::FunctionCall {
call_id,
name,
arguments,
..
} => {
// Flush pending user parts first
if !pending_user_parts.is_empty() {
messages.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Parts(std::mem::take(
&mut pending_user_parts,
)),
});
}
// Parse arguments JSON string to Value
let input = serde_json::from_str(arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
pending_assistant_parts.push(AnthropicContentPart::ToolUse {
id: call_id.clone(),
name: name.clone(),
input,
});
}
Item::FunctionCallOutput {
call_id, output, ..
} => {
// Flush pending assistant parts first
if !pending_assistant_parts.is_empty() {
messages.push(AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicContent::Parts(std::mem::take(
&mut pending_assistant_parts,
)),
});
}
pending_user_parts.push(AnthropicContentPart::ToolResult {
tool_use_id: call_id.clone(),
content: output.clone(),
});
}
Item::Reasoning { text, .. } => {
// Flush pending user parts first
if !pending_user_parts.is_empty() {
messages.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Parts(std::mem::take(
&mut pending_user_parts,
)),
});
}
// Reasoning is treated as assistant text in Anthropic
// (actual thinking blocks are handled differently in streaming)
pending_assistant_parts.push(AnthropicContentPart::Text { text: text.clone() });
}
}
}
// Flush remaining pending parts
self.flush_pending_parts(
&mut messages,
&mut pending_assistant_parts,
&mut pending_user_parts,
);
messages
}
fn flush_pending_parts(
&self,
messages: &mut Vec<AnthropicMessage>,
pending_assistant_parts: &mut Vec<AnthropicContentPart>,
pending_user_parts: &mut Vec<AnthropicContentPart>,
) {
if !pending_assistant_parts.is_empty() {
messages.push(AnthropicMessage {
role: "assistant".to_string(),
content: AnthropicContent::Parts(std::mem::take(pending_assistant_parts)),
});
}
if !pending_user_parts.is_empty() {
messages.push(AnthropicMessage {
role: "user".to_string(),
content: AnthropicContent::Parts(std::mem::take(pending_user_parts)),
});
}
}
fn convert_tool(&self, tool: &ToolDefinition) -> AnthropicTool {
AnthropicTool {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_simple_request() {
let scheme = AnthropicScheme::new();
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514");
assert_eq!(
anthropic_req.system,
Some("You are a helpful assistant.".to_string())
);
assert_eq!(anthropic_req.messages.len(), 1);
assert!(anthropic_req.stream);
}
#[test]
fn test_build_request_with_tool() {
let scheme = AnthropicScheme::new();
let request = Request::new().user("What's the weather?").tool(
ToolDefinition::new("get_weather")
.description("Get current weather")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
})),
);
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
assert_eq!(anthropic_req.tools.len(), 1);
assert_eq!(anthropic_req.tools[0].name, "get_weather");
}
#[test]
fn test_function_call_and_output() {
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request);
assert_eq!(anthropic_req.messages.len(), 3);
assert_eq!(anthropic_req.messages[0].role, "user");
assert_eq!(anthropic_req.messages[1].role, "assistant");
assert_eq!(anthropic_req.messages[2].role, "user");
}
}

View File

@ -0,0 +1,327 @@
//! Gemini SSEイベントパース
//!
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
use crate::llm_client::{
ClientError,
event::{BlockMetadata, BlockStart, BlockStop, BlockType, Event, StopReason, UsageEvent},
};
use serde::Deserialize;
use super::GeminiScheme;
// ============================================================================
// SSEイベントのJSON構造
// ============================================================================
/// Gemini GenerateContentResponse (ストリーミングチャンク)
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateContentResponse {
/// 候補
pub candidates: Option<Vec<Candidate>>,
/// 使用量メタデータ
pub usage_metadata: Option<UsageMetadata>,
/// プロンプトフィードバック
pub prompt_feedback: Option<PromptFeedback>,
/// モデルバージョン
pub model_version: Option<String>,
}
/// 候補
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Candidate {
/// コンテンツ
pub content: Option<CandidateContent>,
/// 完了理由
pub finish_reason: Option<String>,
/// インデックス
pub index: Option<usize>,
/// 安全性評価
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 候補コンテンツ
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct CandidateContent {
/// パーツ
pub parts: Option<Vec<CandidatePart>>,
/// ロール
pub role: Option<String>,
}
/// 候補パーツ
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CandidatePart {
/// テキスト
pub text: Option<String>,
/// 関数呼び出し
pub function_call: Option<FunctionCall>,
}
/// 関数呼び出し
#[derive(Debug, Deserialize)]
pub(crate) struct FunctionCall {
/// 関数名
pub name: String,
/// 引数
pub args: Option<serde_json::Value>,
}
/// 使用量メタデータ
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageMetadata {
/// プロンプトトークン数
pub prompt_token_count: Option<u64>,
/// 候補トークン数
pub candidates_token_count: Option<u64>,
/// 合計トークン数
pub total_token_count: Option<u64>,
}
/// プロンプトフィードバック
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PromptFeedback {
/// ブロック理由
pub block_reason: Option<String>,
/// 安全性評価
pub safety_ratings: Option<Vec<SafetyRating>>,
}
/// 安全性評価
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct SafetyRating {
/// カテゴリ
pub category: Option<String>,
/// 確率
pub probability: Option<String>,
}
// ============================================================================
// イベント変換
// ============================================================================
impl GeminiScheme {
/// SSEデータをEvent型に変換
///
/// # Arguments
/// * `data` - SSEイベントデータJSON文字列
///
/// # Returns
/// * `Ok(Some(Vec<Event>))` - 変換成功
/// * `Ok(None)` - イベントを無視
/// * `Err(ClientError)` - パースエラー
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// データが空または無効な場合はスキップ
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
let response: GenerateContentResponse =
serde_json::from_str(data).map_err(|e| ClientError::Api {
status: None,
code: Some("parse_error".to_string()),
message: format!("Failed to parse Gemini SSE data: {} -> {}", e, data),
})?;
let mut events = Vec::new();
// 使用量メタデータ
if let Some(usage) = response.usage_metadata {
events.push(self.convert_usage(&usage));
}
// 候補を処理
if let Some(candidates) = response.candidates {
for candidate in candidates {
let candidate_index = candidate.index.unwrap_or(0);
if let Some(content) = candidate.content {
if let Some(parts) = content.parts {
for (part_index, part) in parts.iter().enumerate() {
// テキストデルタ
if let Some(text) = &part.text {
if !text.is_empty() {
// Geminiは明示的なBlockStartを送らないため、
// TextDeltaを直接送るTimelineが暗黙的に開始を処理
events.push(Event::text_delta(part_index, text.clone()));
}
}
// 関数呼び出し
if let Some(function_call) = &part.function_call {
// 関数呼び出しの開始
// Geminiでは関数呼び出しは一度に送られることが多い
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
let function_id = format!("call_{}", function_call.name);
events.push(Event::BlockStart(BlockStart {
index: candidate_index * 10 + part_index, // 複合インデックス
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: function_id,
name: function_call.name.clone(),
},
}));
// 引数がある場合はデルタとして送る
if let Some(args) = &function_call.args {
let args_str = serde_json::to_string(args).unwrap_or_default();
if !args_str.is_empty() && args_str != "null" {
events.push(Event::tool_input_delta(
candidate_index * 10 + part_index,
args_str,
));
}
}
}
}
}
}
// 完了理由
if let Some(finish_reason) = candidate.finish_reason {
let stop_reason = match finish_reason.as_str() {
"STOP" => Some(StopReason::EndTurn),
"MAX_TOKENS" => Some(StopReason::MaxTokens),
"SAFETY" | "RECITATION" | "OTHER" => Some(StopReason::EndTurn),
_ => None,
};
// テキストブロックの停止
events.push(Event::BlockStop(BlockStop {
index: candidate_index,
block_type: BlockType::Text,
stop_reason,
}));
}
}
}
if events.is_empty() {
Ok(None)
} else {
Ok(Some(events))
}
}
fn convert_usage(&self, usage: &UsageMetadata) -> Event {
Event::Usage(UsageEvent {
input_tokens: usage.prompt_token_count,
output_tokens: usage.candidates_token_count,
total_tokens: usage.total_token_count,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::event::DeltaContent;
#[test]
fn test_parse_text_response() {
let scheme = GeminiScheme::new();
let data =
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 0);
if let DeltaContent::Text(text) = &delta.delta {
assert_eq!(text, "Hello");
} else {
panic!("Expected text delta");
}
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_usage_metadata() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// Usageイベントが含まれるはず
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
assert!(usage_event.is_some());
if let Event::Usage(usage) = usage_event.unwrap() {
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(5));
assert_eq!(usage.total_tokens, Some(15));
}
}
#[test]
fn test_parse_function_call() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStartイベントがあるはず
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
assert!(start_event.is_some());
if let Event::BlockStart(start) = start_event.unwrap() {
assert_eq!(start.block_type, BlockType::ToolUse);
if let BlockMetadata::ToolUse { id: _, name } = &start.metadata {
assert_eq!(name, "get_weather");
} else {
panic!("Expected ToolUse metadata");
}
}
// 引数デルタもあるはず
let delta_event = events.iter().find(|e| {
if let Event::BlockDelta(d) = e {
matches!(d.delta, DeltaContent::InputJson(_))
} else {
false
}
});
assert!(delta_event.is_some());
}
#[test]
fn test_parse_finish_reason() {
let scheme = GeminiScheme::new();
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// BlockStopイベントがあるはず
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
assert!(stop_event.is_some());
if let Event::BlockStop(stop) = stop_event.unwrap() {
assert_eq!(stop.stop_reason, Some(StopReason::EndTurn));
}
}
#[test]
fn test_parse_empty_data() {
let scheme = GeminiScheme::new();
assert!(scheme.parse_event("").unwrap().is_none());
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
}
}

View File

@ -0,0 +1,29 @@
//! Google Gemini API スキーマ
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod events;
mod request;
/// Geminiスキーマ
///
/// Google Gemini APIのリクエスト/レスポンス変換を担当
#[derive(Debug, Clone, Default)]
pub struct GeminiScheme {
/// ストリーミング関数呼び出し引数を有効にするか
pub stream_function_call_arguments: bool,
}
impl GeminiScheme {
/// 新しいスキーマを作成
pub fn new() -> Self {
Self::default()
}
/// ストリーミング関数呼び出し引数を有効/無効にする
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
self.stream_function_call_arguments = enabled;
self
}
}

View File

@ -0,0 +1,411 @@
//! Gemini Request Builder
//!
//! Converts Open Responses native Item model to Google Gemini API format.
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{
Request,
types::{Item, Role, ToolDefinition},
};
use super::GeminiScheme;
/// Gemini API request body
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiRequest {
/// Contents (conversation history)
pub contents: Vec<GeminiContent>,
/// System instruction
#[serde(skip_serializing_if = "Option::is_none")]
pub system_instruction: Option<GeminiContent>,
/// Tool definitions
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<GeminiTool>,
/// Tool config
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_config: Option<GeminiToolConfig>,
/// Generation config
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_config: Option<GeminiGenerationConfig>,
}
/// Gemini content
#[derive(Debug, Serialize)]
pub(crate) struct GeminiContent {
/// Role
pub role: String,
/// Parts
pub parts: Vec<GeminiPart>,
}
/// Gemini part
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum GeminiPart {
/// Text part
Text { text: String },
/// Function call part
FunctionCall {
#[serde(rename = "functionCall")]
function_call: GeminiFunctionCall,
},
/// Function response part
FunctionResponse {
#[serde(rename = "functionResponse")]
function_response: GeminiFunctionResponse,
},
}
/// Gemini function call
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionCall {
pub name: String,
pub args: Value,
}
/// Gemini function response
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionResponse {
pub name: String,
pub response: GeminiFunctionResponseContent,
}
/// Gemini function response content
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionResponseContent {
pub name: String,
pub content: Value,
}
/// Gemini tool definition
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiTool {
/// Function declarations
pub function_declarations: Vec<GeminiFunctionDeclaration>,
}
/// Gemini function declaration
#[derive(Debug, Serialize)]
pub(crate) struct GeminiFunctionDeclaration {
/// Function name
pub name: String,
/// Description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Parameter schema
pub parameters: Value,
}
/// Gemini tool config
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiToolConfig {
/// Function calling config
pub function_calling_config: GeminiFunctionCallingConfig,
}
/// Gemini function calling config
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiFunctionCallingConfig {
/// Mode: AUTO, ANY, NONE
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// Enable streaming function call arguments
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_function_call_arguments: Option<bool>,
}
/// Gemini generation config
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GeminiGenerationConfig {
/// Max output tokens
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
/// Temperature
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Top P
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// Top K
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
/// Stop sequences
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
}
impl GeminiScheme {
/// Build Gemini request from Request
pub(crate) fn build_request(&self, request: &Request) -> GeminiRequest {
let contents = self.convert_items_to_contents(&request.items);
// System prompt
let system_instruction = request.system_prompt.as_ref().map(|s| GeminiContent {
role: "user".to_string(),
parts: vec![GeminiPart::Text { text: s.clone() }],
});
// Tools
let tools = if request.tools.is_empty() {
vec![]
} else {
vec![GeminiTool {
function_declarations: request.tools.iter().map(|t| self.convert_tool(t)).collect(),
}]
};
// Tool config
let tool_config = if !request.tools.is_empty() {
Some(GeminiToolConfig {
function_calling_config: GeminiFunctionCallingConfig {
mode: Some("AUTO".to_string()),
stream_function_call_arguments: if self.stream_function_call_arguments {
Some(true)
} else {
None
},
},
})
} else {
None
};
// Generation config
let generation_config = Some(GeminiGenerationConfig {
max_output_tokens: request.config.max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
top_k: request.config.top_k,
stop_sequences: request.config.stop_sequences.clone(),
});
GeminiRequest {
contents,
system_instruction,
tools,
tool_config,
generation_config,
}
}
/// Convert Open Responses Items to Gemini Contents
///
/// Gemini uses:
/// - role "user" for user messages and function responses
/// - role "model" for assistant messages and function calls
fn convert_items_to_contents(&self, items: &[Item]) -> Vec<GeminiContent> {
let mut contents = Vec::new();
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
for item in items {
match item {
Item::Message { role, content, .. } => {
// Flush pending parts
self.flush_pending_parts(
&mut contents,
&mut pending_model_parts,
&mut pending_user_parts,
);
let gemini_role = match role {
Role::User => "user",
Role::Assistant => "model",
Role::System => continue, // Skip system role items
};
let parts: Vec<GeminiPart> = content
.iter()
.map(|p| GeminiPart::Text {
text: p.as_text().to_string(),
})
.collect();
contents.push(GeminiContent {
role: gemini_role.to_string(),
parts,
});
}
Item::FunctionCall {
name, arguments, ..
} => {
// Flush pending user parts first
if !pending_user_parts.is_empty() {
contents.push(GeminiContent {
role: "user".to_string(),
parts: std::mem::take(&mut pending_user_parts),
});
}
// Parse arguments
let args = serde_json::from_str(arguments)
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
pending_model_parts.push(GeminiPart::FunctionCall {
function_call: GeminiFunctionCall {
name: name.clone(),
args,
},
});
}
Item::FunctionCallOutput {
call_id, output, ..
} => {
// Flush pending model parts first
if !pending_model_parts.is_empty() {
contents.push(GeminiContent {
role: "model".to_string(),
parts: std::mem::take(&mut pending_model_parts),
});
}
pending_user_parts.push(GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponse {
name: call_id.clone(),
response: GeminiFunctionResponseContent {
name: call_id.clone(),
content: Value::String(output.clone()),
},
},
});
}
Item::Reasoning { text, .. } => {
// Flush pending user parts first
if !pending_user_parts.is_empty() {
contents.push(GeminiContent {
role: "user".to_string(),
parts: std::mem::take(&mut pending_user_parts),
});
}
// Reasoning is treated as model text in Gemini
pending_model_parts.push(GeminiPart::Text { text: text.clone() });
}
}
}
// Flush remaining pending parts
self.flush_pending_parts(
&mut contents,
&mut pending_model_parts,
&mut pending_user_parts,
);
contents
}
fn flush_pending_parts(
&self,
contents: &mut Vec<GeminiContent>,
pending_model_parts: &mut Vec<GeminiPart>,
pending_user_parts: &mut Vec<GeminiPart>,
) {
if !pending_model_parts.is_empty() {
contents.push(GeminiContent {
role: "model".to_string(),
parts: std::mem::take(pending_model_parts),
});
}
if !pending_user_parts.is_empty() {
contents.push(GeminiContent {
role: "user".to_string(),
parts: std::mem::take(pending_user_parts),
});
}
}
fn convert_tool(&self, tool: &ToolDefinition) -> GeminiFunctionDeclaration {
GeminiFunctionDeclaration {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_simple_request() {
let scheme = GeminiScheme::new();
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let gemini_req = scheme.build_request(&request);
assert!(gemini_req.system_instruction.is_some());
assert_eq!(gemini_req.contents.len(), 1);
assert_eq!(gemini_req.contents[0].role, "user");
}
#[test]
fn test_build_request_with_tool() {
let scheme = GeminiScheme::new();
let request = Request::new().user("What's the weather?").tool(
ToolDefinition::new("get_weather")
.description("Get current weather")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
})),
);
let gemini_req = scheme.build_request(&request);
assert_eq!(gemini_req.tools.len(), 1);
assert_eq!(gemini_req.tools[0].function_declarations.len(), 1);
assert_eq!(
gemini_req.tools[0].function_declarations[0].name,
"get_weather"
);
assert!(gemini_req.tool_config.is_some());
}
#[test]
fn test_assistant_role_is_model() {
let scheme = GeminiScheme::new();
let request = Request::new().user("Hello").assistant("Hi there!");
let gemini_req = scheme.build_request(&request);
assert_eq!(gemini_req.contents.len(), 2);
assert_eq!(gemini_req.contents[0].role, "user");
assert_eq!(gemini_req.contents[1].role, "model");
}
#[test]
fn test_function_call_and_output() {
let scheme = GeminiScheme::new();
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
let gemini_req = scheme.build_request(&request);
assert_eq!(gemini_req.contents.len(), 3);
assert_eq!(gemini_req.contents[0].role, "user");
assert_eq!(gemini_req.contents[1].role, "model");
assert_eq!(gemini_req.contents[2].role, "user");
}
}

View File

@ -0,0 +1,9 @@
//! APIスキーマ定義
//!
//! 各APIスキーマごとの変換ロジック
//! - リクエスト変換: Request → プロバイダ固有JSON
//! - レスポンス変換: SSEイベント → Event
pub mod anthropic;
pub mod gemini;
pub mod openai;

View File

@ -0,0 +1,212 @@
//! OpenAI SSEイベントパース
use crate::llm_client::{
ClientError,
event::{Event, StopReason, UsageEvent},
};
use serde::Deserialize;
use super::OpenAIScheme;
/// OpenAI Streaming Chat Response Chunk
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct ChatCompletionChunk {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ChunkChoice>,
pub usage: Option<ChunkUsage>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkChoice {
pub index: usize,
pub delta: ChunkDelta,
pub finish_reason: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkDelta {
pub role: Option<String>,
pub content: Option<String>,
pub tool_calls: Option<Vec<ChunkToolCall>>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkToolCall {
pub index: usize,
pub id: Option<String>,
#[serde(rename = "type")]
pub call_type: Option<String>,
pub function: Option<ChunkFunction>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkFunction {
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChunkUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
impl OpenAIScheme {
/// SSEデータのパースとEventへの変換
///
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
/// Timeline層が暗黙的なBlockStartを処理する。
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
if data == "[DONE]" {
return Ok(None);
}
let chunk: ChatCompletionChunk =
serde_json::from_str(data).map_err(|e| ClientError::Api {
status: None,
code: Some("parse_error".to_string()),
message: format!("Failed to parse SSE data: {} -> {}", e, data),
})?;
let mut events = Vec::new();
// Usage handling
if let Some(usage) = chunk.usage {
events.push(Event::Usage(UsageEvent {
input_tokens: Some(usage.prompt_tokens),
output_tokens: Some(usage.completion_tokens),
total_tokens: Some(usage.total_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}));
}
for choice in chunk.choices {
// Text Content Delta
if let Some(content) = choice.delta.content {
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
// Timeline層が暗黙的なBlockStartを処理する
events.push(Event::text_delta(choice.index, content));
}
// Tool Call Delta
if let Some(tool_calls) = choice.delta.tool_calls {
for tool_call in tool_calls {
// Start of tool call (has ID)
if let Some(id) = tool_call.id {
let name = tool_call
.function
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or_default();
events.push(Event::tool_use_start(tool_call.index, id, name));
}
// Arguments delta
if let Some(function) = tool_call.function {
if let Some(args) = function.arguments {
if !args.is_empty() {
events.push(Event::tool_input_delta(tool_call.index, args));
}
}
}
}
}
// Finish Reason
if let Some(finish_reason) = choice.finish_reason {
let stop_reason = match finish_reason.as_str() {
"stop" => Some(StopReason::EndTurn),
"length" => Some(StopReason::MaxTokens),
"tool_calls" | "function_call" => Some(StopReason::ToolUse),
_ => Some(StopReason::EndTurn),
};
let is_tool_finish =
finish_reason == "tool_calls" || finish_reason == "function_call";
if is_tool_finish {
// ツール呼び出し終了
// Note: OpenAIはどのツールが終了したか明示しないため、
// Timeline層で適切に処理する必要がある
} else {
// テキスト終了
events.push(Event::text_block_stop(choice.index, stop_reason));
}
}
}
if events.is_empty() {
Ok(None)
} else {
Ok(Some(events))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::event::DeltaContent;
#[test]
fn test_parse_text_delta() {
let scheme = OpenAIScheme::new();
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let events = scheme.parse_event(data).unwrap().unwrap();
// OpenAIはBlockStartを発行しないため、デルタのみ
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 0);
if let DeltaContent::Text(text) = &delta.delta {
assert_eq!(text, "Hello");
} else {
panic!("Expected text delta");
}
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_tool_call() {
let scheme = OpenAIScheme::new();
// Start of tool call
let data_start = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
let events = scheme.parse_event(data_start).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockStart(start) = &events[0] {
assert_eq!(start.index, 0);
if let crate::llm_client::event::BlockMetadata::ToolUse { id, name } = &start.metadata {
assert_eq!(id, "call_abc");
assert_eq!(name, "get_weather");
} else {
panic!("Expected ToolUse metadata");
}
}
// Tool arguments delta
let data_arg = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}}"}}]},"finish_reason":null}]}"#;
let events = scheme.parse_event(data_arg).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
if let DeltaContent::InputJson(json) = &delta.delta {
assert_eq!(json, "{}}");
} else {
panic!("Expected input json delta");
}
}
}
}

View File

@ -0,0 +1,31 @@
//! OpenAI Chat Completions API スキーマ
//!
//! - リクエストJSON生成
//! - SSEイベントパース → Event変換
mod events;
mod request;
/// OpenAIスキーマ
///
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
#[derive(Debug, Clone, Default)]
pub struct OpenAIScheme {
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
pub model: Option<String>,
/// レガシーなmax_tokensを使用するか (Ollama互換用)
pub use_legacy_max_tokens: bool,
}
impl OpenAIScheme {
/// 新しいスキーマを作成
pub fn new() -> Self {
Self::default()
}
/// レガシーなmax_tokensを使用するか設定
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
self.use_legacy_max_tokens = use_legacy;
self
}
}

View File

@ -0,0 +1,361 @@
//! OpenAI Request Builder
//!
//! Converts Open Responses native Item model to OpenAI Chat Completions API format.
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{
Request,
types::{Item, Role, ToolDefinition},
};
use super::OpenAIScheme;
/// OpenAI API request body
#[derive(Debug, Serialize)]
pub(crate) struct OpenAIRequest {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>, // Legacy field for compatibility (e.g. Ollama)
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop: Vec<String>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
pub messages: Vec<OpenAIMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<OpenAITool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct StreamOptions {
pub include_usage: bool,
}
/// OpenAI message
#[derive(Debug, Serialize)]
pub(crate) struct OpenAIMessage {
pub role: String,
pub content: Option<OpenAIContent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<OpenAIToolCall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// OpenAI content
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum OpenAIContent {
Text(String),
Parts(Vec<OpenAIContentPart>),
}
/// OpenAI content part
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub(crate) enum OpenAIContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
#[derive(Debug, Serialize)]
pub(crate) struct ImageUrl {
pub url: String,
}
/// OpenAI tool definition
#[derive(Debug, Serialize)]
pub(crate) struct OpenAITool {
pub r#type: String,
pub function: OpenAIToolFunction,
}
#[derive(Debug, Serialize)]
pub(crate) struct OpenAIToolFunction {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
}
/// OpenAI tool call in message
#[derive(Debug, Serialize)]
pub(crate) struct OpenAIToolCall {
pub id: String,
pub r#type: String,
pub function: OpenAIToolCallFunction,
}
#[derive(Debug, Serialize)]
pub(crate) struct OpenAIToolCallFunction {
pub name: String,
pub arguments: String,
}
impl OpenAIScheme {
/// Build OpenAI request from Request
pub(crate) fn build_request(&self, model: &str, request: &Request) -> OpenAIRequest {
let mut messages = Vec::new();
// Add system message if present
if let Some(system) = &request.system_prompt {
messages.push(OpenAIMessage {
role: "system".to_string(),
content: Some(OpenAIContent::Text(system.clone())),
tool_calls: vec![],
tool_call_id: None,
name: None,
});
}
// Convert items to messages
messages.extend(self.convert_items_to_messages(&request.items));
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
let (max_tokens, max_completion_tokens) = if self.use_legacy_max_tokens {
(request.config.max_tokens, None)
} else {
(None, request.config.max_tokens)
};
OpenAIRequest {
model: model.to_string(),
max_completion_tokens,
max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
stop: request.config.stop_sequences.clone(),
stream: true,
stream_options: Some(StreamOptions {
include_usage: true,
}),
messages,
tools,
tool_choice: None,
}
}
/// Convert Open Responses Items to OpenAI Messages
///
/// OpenAI uses a message-based model where:
/// - User messages have role "user"
/// - Assistant messages have role "assistant"
/// - Tool calls are within assistant messages as tool_calls array
/// - Tool results have role "tool" with tool_call_id
fn convert_items_to_messages(&self, items: &[Item]) -> Vec<OpenAIMessage> {
let mut messages = Vec::new();
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
let mut pending_assistant_text: Option<String> = None;
for item in items {
match item {
Item::Message { role, content, .. } => {
// Flush pending tool calls
self.flush_pending_assistant(
&mut messages,
&mut pending_tool_calls,
&mut pending_assistant_text,
);
let openai_role = match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
};
let text_content: String = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
messages.push(OpenAIMessage {
role: openai_role.to_string(),
content: Some(OpenAIContent::Text(text_content)),
tool_calls: vec![],
tool_call_id: None,
name: None,
});
}
Item::FunctionCall {
call_id,
name,
arguments,
..
} => {
pending_tool_calls.push(OpenAIToolCall {
id: call_id.clone(),
r#type: "function".to_string(),
function: OpenAIToolCallFunction {
name: name.clone(),
arguments: arguments.clone(),
},
});
}
Item::FunctionCallOutput {
call_id, output, ..
} => {
// Flush pending tool calls before tool result
self.flush_pending_assistant(
&mut messages,
&mut pending_tool_calls,
&mut pending_assistant_text,
);
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(output.clone())),
tool_calls: vec![],
tool_call_id: Some(call_id.clone()),
name: None,
});
}
Item::Reasoning { text, .. } => {
// Reasoning is treated as assistant text in OpenAI
// (OpenAI doesn't have native reasoning support like Claude)
if let Some(ref mut existing) = pending_assistant_text {
existing.push_str(text);
} else {
pending_assistant_text = Some(text.clone());
}
}
}
}
// Flush remaining pending items
self.flush_pending_assistant(
&mut messages,
&mut pending_tool_calls,
&mut pending_assistant_text,
);
messages
}
fn flush_pending_assistant(
&self,
messages: &mut Vec<OpenAIMessage>,
pending_tool_calls: &mut Vec<OpenAIToolCall>,
pending_assistant_text: &mut Option<String>,
) {
if !pending_tool_calls.is_empty() || pending_assistant_text.is_some() {
messages.push(OpenAIMessage {
role: "assistant".to_string(),
content: pending_assistant_text.take().map(OpenAIContent::Text),
tool_calls: std::mem::take(pending_tool_calls),
tool_call_id: None,
name: None,
});
}
}
fn convert_tool(&self, tool: &ToolDefinition) -> OpenAITool {
OpenAITool {
r#type: "function".to_string(),
function: OpenAIToolFunction {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_simple_request() {
let scheme = OpenAIScheme::new();
let request = Request::new().system("System prompt").user("Hello");
let body = scheme.build_request("gpt-4o", &request);
assert_eq!(body.model, "gpt-4o");
assert_eq!(body.messages.len(), 2);
assert_eq!(body.messages[0].role, "system");
assert_eq!(body.messages[1].role, "user");
if let Some(OpenAIContent::Text(text)) = &body.messages[0].content {
assert_eq!(text, "System prompt");
} else {
panic!("Expected text content");
}
}
#[test]
fn test_build_request_with_tool() {
let scheme = OpenAIScheme::new();
let request = Request::new()
.user("Check weather")
.tool(ToolDefinition::new("weather").description("Get weather"));
let body = scheme.build_request("gpt-4o", &request);
assert_eq!(body.tools.len(), 1);
assert_eq!(body.tools[0].function.name, "weather");
}
#[test]
fn test_build_request_legacy_max_tokens() {
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
let request = Request::new().user("Hello").max_tokens(100);
let body = scheme.build_request("llama3", &request);
assert_eq!(body.max_tokens, Some(100));
assert!(body.max_completion_tokens.is_none());
}
#[test]
fn test_build_request_modern_max_tokens() {
let scheme = OpenAIScheme::new();
let request = Request::new().user("Hello").max_tokens(100);
let body = scheme.build_request("gpt-4o", &request);
assert_eq!(body.max_completion_tokens, Some(100));
assert!(body.max_tokens.is_none());
}
#[test]
fn test_function_call_and_output() {
let scheme = OpenAIScheme::new();
let request = Request::new()
.user("Check weather")
.item(Item::function_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
let body = scheme.build_request("gpt-4o", &request);
assert_eq!(body.messages.len(), 3);
assert_eq!(body.messages[0].role, "user");
assert_eq!(body.messages[1].role, "assistant");
assert_eq!(body.messages[1].tool_calls.len(), 1);
assert_eq!(body.messages[2].role, "tool");
}
}

View File

@ -0,0 +1,494 @@
//! Open Responses Event Parser
//!
//! Parses SSE events from the Open Responses API into internal Event types.
use serde::Deserialize;
use crate::llm_client::{
event::{
BlockMetadata, BlockStart, BlockStop, DeltaContent, ErrorEvent, Event, ResponseStatus,
StatusEvent, StopReason, UsageEvent,
},
ClientError,
};
// =============================================================================
// Open Responses SSE Event Types
// =============================================================================
/// Response created event
#[derive(Debug, Deserialize)]
pub struct ResponseCreatedEvent {
pub response: ResponseObject,
}
/// Response object
#[derive(Debug, Deserialize)]
pub struct ResponseObject {
pub id: String,
pub status: String,
#[serde(default)]
pub output: Vec<OutputItem>,
pub usage: Option<UsageObject>,
}
/// Output item in response
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutputItem {
Message {
id: String,
role: String,
#[serde(default)]
content: Vec<ContentPartObject>,
},
FunctionCall {
id: String,
call_id: String,
name: String,
arguments: String,
},
Reasoning {
id: String,
#[serde(default)]
text: String,
},
}
/// Content part object
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPartObject {
OutputText { text: String },
InputText { text: String },
Refusal { refusal: String },
}
/// Usage object
#[derive(Debug, Deserialize)]
pub struct UsageObject {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
}
/// Output item added event
#[derive(Debug, Deserialize)]
pub struct OutputItemAddedEvent {
pub output_index: usize,
pub item: OutputItem,
}
/// Text delta event
#[derive(Debug, Deserialize)]
pub struct TextDeltaEvent {
pub output_index: usize,
pub content_index: usize,
pub delta: String,
}
/// Text done event
#[derive(Debug, Deserialize)]
pub struct TextDoneEvent {
pub output_index: usize,
pub content_index: usize,
pub text: String,
}
/// Function call arguments delta event
#[derive(Debug, Deserialize)]
pub struct FunctionCallArgumentsDeltaEvent {
pub output_index: usize,
pub call_id: String,
pub delta: String,
}
/// Function call arguments done event
#[derive(Debug, Deserialize)]
pub struct FunctionCallArgumentsDoneEvent {
pub output_index: usize,
pub call_id: String,
pub arguments: String,
}
/// Reasoning delta event
#[derive(Debug, Deserialize)]
pub struct ReasoningDeltaEvent {
pub output_index: usize,
pub delta: String,
}
/// Reasoning done event
#[derive(Debug, Deserialize)]
pub struct ReasoningDoneEvent {
pub output_index: usize,
pub text: String,
}
/// Content part done event
#[derive(Debug, Deserialize)]
pub struct ContentPartDoneEvent {
pub output_index: usize,
pub content_index: usize,
pub part: ContentPartObject,
}
/// Output item done event
#[derive(Debug, Deserialize)]
pub struct OutputItemDoneEvent {
pub output_index: usize,
pub item: OutputItem,
}
/// Response done event
#[derive(Debug, Deserialize)]
pub struct ResponseDoneEvent {
pub response: ResponseObject,
}
/// Error event from API
#[derive(Debug, Deserialize)]
pub struct ApiErrorEvent {
pub error: ApiError,
}
/// API error details
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Event Parsing
// =============================================================================
/// Parse SSE event into internal Event(s)
///
/// Returns `Ok(None)` for events that should be ignored (e.g., heartbeats)
/// Returns `Ok(Some(vec))` for events that produce one or more internal Events
pub fn parse_event(event_type: &str, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
// Skip empty data
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
let events = match event_type {
// Response lifecycle
"response.created" => {
let _event: ResponseCreatedEvent = parse_json(data)?;
Some(vec![Event::Status(StatusEvent {
status: ResponseStatus::Started,
})])
}
"response.in_progress" => {
// Just a status update, no action needed
None
}
"response.completed" | "response.done" => {
let event: ResponseDoneEvent = parse_json(data)?;
let mut events = Vec::new();
// Emit usage if present
if let Some(usage) = event.response.usage {
events.push(Event::Usage(UsageEvent {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}));
}
events.push(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}));
Some(events)
}
"response.failed" => {
// Try to parse error
if let Ok(error_event) = parse_json::<ApiErrorEvent>(data) {
Some(vec![
Event::Error(ErrorEvent {
code: error_event.error.code,
message: error_event.error.message,
}),
Event::Status(StatusEvent {
status: ResponseStatus::Failed,
}),
])
} else {
Some(vec![Event::Status(StatusEvent {
status: ResponseStatus::Failed,
})])
}
}
// Output item events
"response.output_item.added" => {
let event: OutputItemAddedEvent = parse_json(data)?;
Some(vec![convert_item_added(&event)])
}
"response.output_item.done" => {
let event: OutputItemDoneEvent = parse_json(data)?;
Some(vec![convert_item_done(&event)])
}
// Text content events
"response.output_text.delta" => {
let event: TextDeltaEvent = parse_json(data)?;
Some(vec![Event::text_delta(event.output_index, &event.delta)])
}
"response.output_text.done" => {
// Text done - we'll handle stop in output_item.done
let _event: TextDoneEvent = parse_json(data)?;
None
}
// Content part events
"response.content_part.added" => {
// Content part added - we handle this via output_item.added
None
}
"response.content_part.done" => {
// Content part done - we handle stop in output_item.done
None
}
// Function call events
"response.function_call_arguments.delta" => {
let event: FunctionCallArgumentsDeltaEvent = parse_json(data)?;
Some(vec![Event::BlockDelta(crate::llm_client::event::BlockDelta {
index: event.output_index,
delta: DeltaContent::InputJson(event.delta),
})])
}
"response.function_call_arguments.done" => {
// Arguments done - we handle stop in output_item.done
let _event: FunctionCallArgumentsDoneEvent = parse_json(data)?;
None
}
// Reasoning events
"response.reasoning.delta" | "response.reasoning_summary_text.delta" => {
let event: ReasoningDeltaEvent = parse_json(data)?;
Some(vec![Event::BlockDelta(crate::llm_client::event::BlockDelta {
index: event.output_index,
delta: DeltaContent::Thinking(event.delta),
})])
}
"response.reasoning.done" | "response.reasoning_summary_text.done" => {
// Reasoning done - we handle stop in output_item.done
let _event: ReasoningDoneEvent = parse_json(data)?;
None
}
// Error event
"error" => {
let event: ApiErrorEvent = parse_json(data)?;
Some(vec![Event::Error(ErrorEvent {
code: event.error.code,
message: event.error.message,
})])
}
// Unknown event type - ignore
_ => {
tracing::debug!(event_type = event_type, "Unknown Open Responses event type");
None
}
};
Ok(events)
}
fn parse_json<T: serde::de::DeserializeOwned>(data: &str) -> Result<T, ClientError> {
serde_json::from_str(data).map_err(|e| ClientError::Parse(e.to_string()))
}
fn convert_item_added(event: &OutputItemAddedEvent) -> Event {
match &event.item {
OutputItem::Message { id, role: _, content: _ } => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::Text,
metadata: BlockMetadata::Text,
}),
OutputItem::FunctionCall {
id,
call_id,
name,
arguments: _,
} => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: call_id.clone(),
name: name.clone(),
},
}),
OutputItem::Reasoning { id, text: _ } => Event::BlockStart(BlockStart {
index: event.output_index,
block_type: crate::llm_client::event::BlockType::Thinking,
metadata: BlockMetadata::Thinking,
}),
}
}
fn convert_item_done(event: &OutputItemDoneEvent) -> Event {
let stop_reason = match &event.item {
OutputItem::FunctionCall { .. } => Some(StopReason::ToolUse),
_ => Some(StopReason::EndTurn),
};
Event::BlockStop(BlockStop {
index: event.output_index,
stop_reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_response_created() {
let data = r#"{"response":{"id":"resp_123","status":"in_progress","output":[]}}"#;
let events = parse_event("response.created", data).unwrap().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
Event::Status(StatusEvent {
status: ResponseStatus::Started
})
));
}
#[test]
fn test_parse_text_delta() {
let data = r#"{"output_index":0,"content_index":0,"delta":"Hello"}"#;
let events = parse_event("response.output_text.delta", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 0);
assert!(matches!(&delta.delta, DeltaContent::Text(t) if t == "Hello"));
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_output_item_added_message() {
let data = r#"{"output_index":0,"item":{"type":"message","id":"msg_123","role":"assistant","content":[]}}"#;
let events = parse_event("response.output_item.added", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockStart(start) = &events[0] {
assert_eq!(start.index, 0);
assert!(matches!(
start.block_type,
crate::llm_client::event::BlockType::Text
));
} else {
panic!("Expected BlockStart");
}
}
#[test]
fn test_parse_output_item_added_function_call() {
let data = r#"{"output_index":1,"item":{"type":"function_call","id":"fc_123","call_id":"call_456","name":"get_weather","arguments":""}}"#;
let events = parse_event("response.output_item.added", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockStart(start) = &events[0] {
assert_eq!(start.index, 1);
assert!(matches!(
start.block_type,
crate::llm_client::event::BlockType::ToolUse
));
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
assert_eq!(id, "call_456");
assert_eq!(name, "get_weather");
} else {
panic!("Expected ToolUse metadata");
}
} else {
panic!("Expected BlockStart");
}
}
#[test]
fn test_parse_function_call_arguments_delta() {
let data = r#"{"output_index":1,"call_id":"call_456","delta":"{\"city\":"}"#;
let events = parse_event("response.function_call_arguments.delta", data)
.unwrap()
.unwrap();
assert_eq!(events.len(), 1);
if let Event::BlockDelta(delta) = &events[0] {
assert_eq!(delta.index, 1);
assert!(matches!(
&delta.delta,
DeltaContent::InputJson(s) if s == "{\"city\":"
));
} else {
panic!("Expected BlockDelta");
}
}
#[test]
fn test_parse_response_completed() {
let data = r#"{"response":{"id":"resp_123","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
let events = parse_event("response.completed", data).unwrap().unwrap();
assert_eq!(events.len(), 2);
// First event should be usage
if let Event::Usage(usage) = &events[0] {
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.total_tokens, Some(30));
} else {
panic!("Expected Usage event");
}
// Second event should be status
assert!(matches!(
events[1],
Event::Status(StatusEvent {
status: ResponseStatus::Completed
})
));
}
#[test]
fn test_parse_error() {
let data = r#"{"error":{"code":"rate_limit","message":"Too many requests"}}"#;
let events = parse_event("error", data).unwrap().unwrap();
assert_eq!(events.len(), 1);
if let Event::Error(err) = &events[0] {
assert_eq!(err.code, Some("rate_limit".to_string()));
assert_eq!(err.message, "Too many requests");
} else {
panic!("Expected Error event");
}
}
#[test]
fn test_parse_unknown_event() {
let data = r#"{}"#;
let events = parse_event("some.unknown.event", data).unwrap();
assert!(events.is_none());
}
}

View File

@ -0,0 +1,49 @@
//! Open Responses Scheme
//!
//! Handles request/response conversion for the Open Responses API.
//! Since our internal types are already Open Responses native, this scheme
//! primarily passes through data with minimal transformation.
mod events;
mod request;
use crate::llm_client::{ClientError, Request};
pub use events::*;
pub use request::*;
/// Open Responses Scheme
///
/// Handles conversion between internal types and the Open Responses wire format.
#[derive(Debug, Clone, Default)]
pub struct OpenResponsesScheme {
/// Optional model override
pub model: Option<String>,
}
impl OpenResponsesScheme {
/// Create a new OpenResponsesScheme
pub fn new() -> Self {
Self::default()
}
/// Set the model
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Build Open Responses request from internal Request
pub fn build_request(&self, model: &str, request: &Request) -> OpenResponsesRequest {
build_request(model, request)
}
/// Parse SSE event data into internal Event(s)
pub fn parse_event(
&self,
event_type: &str,
data: &str,
) -> Result<Option<Vec<crate::llm_client::Event>>, ClientError> {
parse_event(event_type, data)
}
}

View File

@ -0,0 +1,285 @@
//! Open Responses Request Builder
//!
//! Converts internal Request/Item types to Open Responses API format.
//! Since our internal types are already Open Responses native, this is
//! mostly a direct serialization with some field renaming.
use serde::Serialize;
use serde_json::Value;
use crate::llm_client::{types::Item, Request, ToolDefinition};
/// Open Responses API request body
#[derive(Debug, Serialize)]
pub struct OpenResponsesRequest {
/// Model identifier
pub model: String,
/// Input items (conversation history)
pub input: Vec<OpenResponsesItem>,
/// System instructions
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// Tool definitions
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<OpenResponsesTool>,
/// Enable streaming
pub stream: bool,
/// Maximum output tokens
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
/// Temperature
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// Top P (nucleus sampling)
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
}
/// Open Responses input item
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OpenResponsesItem {
/// Message item
Message {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
role: String,
content: Vec<OpenResponsesContentPart>,
},
/// Function call item
FunctionCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
call_id: String,
name: String,
arguments: String,
},
/// Function call output item
FunctionCallOutput {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
call_id: String,
output: String,
},
/// Reasoning item
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
text: String,
},
}
/// Open Responses content part
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OpenResponsesContentPart {
/// Input text (for user messages)
InputText { text: String },
/// Output text (for assistant messages)
OutputText { text: String },
/// Refusal
Refusal { refusal: String },
}
/// Open Responses tool definition
#[derive(Debug, Serialize)]
pub struct OpenResponsesTool {
/// Tool type (always "function")
pub r#type: String,
/// Function definition
pub name: String,
/// Description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Parameters schema
pub parameters: Value,
}
/// Build Open Responses request from internal Request
pub fn build_request(model: &str, request: &Request) -> OpenResponsesRequest {
let input = request.items.iter().map(convert_item).collect();
let tools = request.tools.iter().map(convert_tool).collect();
OpenResponsesRequest {
model: model.to_string(),
input,
instructions: request.system_prompt.clone(),
tools,
stream: true,
max_output_tokens: request.config.max_tokens,
temperature: request.config.temperature,
top_p: request.config.top_p,
}
}
fn convert_item(item: &Item) -> OpenResponsesItem {
match item {
Item::Message {
id,
role,
content,
status: _,
} => {
let role_str = match role {
crate::llm_client::types::Role::User => "user",
crate::llm_client::types::Role::Assistant => "assistant",
crate::llm_client::types::Role::System => "system",
};
let parts = content
.iter()
.map(|p| match p {
crate::llm_client::types::ContentPart::InputText { text } => {
OpenResponsesContentPart::InputText { text: text.clone() }
}
crate::llm_client::types::ContentPart::OutputText { text } => {
OpenResponsesContentPart::OutputText { text: text.clone() }
}
crate::llm_client::types::ContentPart::Refusal { refusal } => {
OpenResponsesContentPart::Refusal {
refusal: refusal.clone(),
}
}
})
.collect();
OpenResponsesItem::Message {
id: id.clone(),
role: role_str.to_string(),
content: parts,
}
}
Item::FunctionCall {
id,
call_id,
name,
arguments,
status: _,
} => OpenResponsesItem::FunctionCall {
id: id.clone(),
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
},
Item::FunctionCallOutput {
id,
call_id,
output,
} => OpenResponsesItem::FunctionCallOutput {
id: id.clone(),
call_id: call_id.clone(),
output: output.clone(),
},
Item::Reasoning {
id,
text,
status: _,
} => OpenResponsesItem::Reasoning {
id: id.clone(),
text: text.clone(),
},
}
}
fn convert_tool(tool: &ToolDefinition) -> OpenResponsesTool {
OpenResponsesTool {
r#type: "function".to_string(),
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::types::Item;
#[test]
fn test_build_simple_request() {
let request = Request::new()
.system("You are a helpful assistant.")
.user("Hello!");
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.model, "gpt-4o");
assert_eq!(
or_req.instructions,
Some("You are a helpful assistant.".to_string())
);
assert_eq!(or_req.input.len(), 1);
assert!(or_req.stream);
}
#[test]
fn test_build_request_with_tool() {
let request = Request::new().user("What's the weather?").tool(
ToolDefinition::new("get_weather")
.description("Get current weather")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
})),
);
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.tools.len(), 1);
assert_eq!(or_req.tools[0].name, "get_weather");
assert_eq!(or_req.tools[0].r#type, "function");
}
#[test]
fn test_function_call_and_output() {
let request = Request::new()
.user("What's the weather?")
.item(Item::function_call(
"call_123",
"get_weather",
r#"{"city":"Tokyo"}"#,
))
.item(Item::function_call_output("call_123", "Sunny, 25°C"));
let or_req = build_request("gpt-4o", &request);
assert_eq!(or_req.input.len(), 3);
// Check function call
if let OpenResponsesItem::FunctionCall { call_id, name, .. } = &or_req.input[1] {
assert_eq!(call_id, "call_123");
assert_eq!(name, "get_weather");
} else {
panic!("Expected FunctionCall");
}
// Check function call output
if let OpenResponsesItem::FunctionCallOutput { call_id, output, .. } = &or_req.input[2] {
assert_eq!(call_id, "call_123");
assert_eq!(output, "Sunny, 25°C");
} else {
panic!("Expected FunctionCallOutput");
}
}
}

View File

@ -0,0 +1,575 @@
//! LLM Client Common Types - Open Responses Native
//!
//! This module defines types that are natively aligned with the Open Responses specification.
//! The core abstraction is `Item` which represents different types of conversation elements:
//! - Message items (user/assistant messages with content parts)
//! - FunctionCall items (tool invocations)
//! - FunctionCallOutput items (tool results)
//! - Reasoning items (extended thinking)
use serde::{Deserialize, Serialize};
// ============================================================================
// Item - The core unit of conversation
// ============================================================================
/// Item ID type for tracking items in a conversation
pub type ItemId = String;
/// Call ID type for linking function calls to their outputs
pub type CallId = String;
/// Conversation item - the primary unit in Open Responses
///
/// Items represent discrete elements in a conversation. Unlike traditional
/// message-based APIs, Open Responses treats tool calls and reasoning as
/// first-class items rather than parts of messages.
///
/// # Examples
///
/// ```ignore
/// use llm_worker::Item;
///
/// // User message
/// let user_item = Item::user_message("Hello!");
///
/// // Assistant message
/// let assistant_item = Item::assistant_message("Hi there!");
///
/// // Function call
/// let call = Item::function_call("call_123", "get_weather", json!({"city": "Tokyo"}));
///
/// // Function call output
/// let result = Item::function_call_output("call_123", "Sunny, 25°C");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Item {
/// User or assistant message with content parts
Message {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Message role
role: Role,
/// Content parts
content: Vec<ContentPart>,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
},
/// Function (tool) call from the assistant
FunctionCall {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Call ID for linking to output
call_id: CallId,
/// Function name
name: String,
/// Function arguments as JSON string
arguments: String,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
},
/// Function (tool) call output/result
FunctionCallOutput {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Call ID linking to the function call
call_id: CallId,
/// Output content
output: String,
},
/// Reasoning/thinking item
Reasoning {
/// Optional item ID
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<ItemId>,
/// Reasoning text
text: String,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
},
}
impl Item {
// ========================================================================
// Message constructors
// ========================================================================
/// Create a user message item with text content
pub fn user_message(text: impl Into<String>) -> Self {
Self::Message {
id: None,
role: Role::User,
content: vec![ContentPart::InputText { text: text.into() }],
status: None,
}
}
/// Create a user message item with multiple content parts
pub fn user_message_parts(parts: Vec<ContentPart>) -> Self {
Self::Message {
id: None,
role: Role::User,
content: parts,
status: None,
}
}
/// Create an assistant message item with text content
pub fn assistant_message(text: impl Into<String>) -> Self {
Self::Message {
id: None,
role: Role::Assistant,
content: vec![ContentPart::OutputText { text: text.into() }],
status: None,
}
}
/// Create an assistant message item with multiple content parts
pub fn assistant_message_parts(parts: Vec<ContentPart>) -> Self {
Self::Message {
id: None,
role: Role::Assistant,
content: parts,
status: None,
}
}
// ========================================================================
// Function call constructors
// ========================================================================
/// Create a function call item
pub fn function_call(
call_id: impl Into<String>,
name: impl Into<String>,
arguments: impl Into<String>,
) -> Self {
Self::FunctionCall {
id: None,
call_id: call_id.into(),
name: name.into(),
arguments: arguments.into(),
status: None,
}
}
/// Create a function call item from a JSON value
pub fn function_call_json(
call_id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self::function_call(call_id, name, arguments.to_string())
}
/// Create a function call output item
pub fn function_call_output(call_id: impl Into<String>, output: impl Into<String>) -> Self {
Self::FunctionCallOutput {
id: None,
call_id: call_id.into(),
output: output.into(),
}
}
// ========================================================================
// Reasoning constructors
// ========================================================================
/// Create a reasoning item
pub fn reasoning(text: impl Into<String>) -> Self {
Self::Reasoning {
id: None,
text: text.into(),
status: None,
}
}
// ========================================================================
// Builder methods
// ========================================================================
/// Set the item ID
pub fn with_id(mut self, id: impl Into<String>) -> Self {
match &mut self {
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
Self::FunctionCall { id: item_id, .. } => *item_id = Some(id.into()),
Self::FunctionCallOutput { id: item_id, .. } => *item_id = Some(id.into()),
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
}
self
}
/// Set the item status
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
match &mut self {
Self::Message { status, .. } => *status = Some(new_status),
Self::FunctionCall { status, .. } => *status = Some(new_status),
Self::FunctionCallOutput { .. } => {} // Output items don't have status
Self::Reasoning { status, .. } => *status = Some(new_status),
}
self
}
// ========================================================================
// Accessors
// ========================================================================
/// Get the item ID if set
pub fn id(&self) -> Option<&str> {
match self {
Self::Message { id, .. } => id.as_deref(),
Self::FunctionCall { id, .. } => id.as_deref(),
Self::FunctionCallOutput { id, .. } => id.as_deref(),
Self::Reasoning { id, .. } => id.as_deref(),
}
}
/// Get the item type as a string
pub fn item_type(&self) -> &'static str {
match self {
Self::Message { .. } => "message",
Self::FunctionCall { .. } => "function_call",
Self::FunctionCallOutput { .. } => "function_call_output",
Self::Reasoning { .. } => "reasoning",
}
}
/// Check if this is a user message
pub fn is_user_message(&self) -> bool {
matches!(
self,
Self::Message {
role: Role::User,
..
}
)
}
/// Check if this is an assistant message
pub fn is_assistant_message(&self) -> bool {
matches!(
self,
Self::Message {
role: Role::Assistant,
..
}
)
}
/// Check if this is a function call
pub fn is_function_call(&self) -> bool {
matches!(self, Self::FunctionCall { .. })
}
/// Check if this is a function call output
pub fn is_function_call_output(&self) -> bool {
matches!(self, Self::FunctionCallOutput { .. })
}
/// Check if this is a reasoning item
pub fn is_reasoning(&self) -> bool {
matches!(self, Self::Reasoning { .. })
}
/// Get text content if this is a simple text message
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Message { content, .. } if content.len() == 1 => match &content[0] {
ContentPart::InputText { text } => Some(text),
ContentPart::OutputText { text } => Some(text),
_ => None,
},
_ => None,
}
}
}
// ============================================================================
// Content Parts - Components within message items
// ============================================================================
/// Content part within a message item
///
/// Open Responses distinguishes between input and output content types.
/// Input types are used in user messages, output types in assistant messages.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
/// Input text (for user messages)
InputText {
/// The text content
text: String,
},
/// Output text (for assistant messages)
OutputText {
/// The text content
text: String,
},
/// Refusal content (for assistant messages)
Refusal {
/// The refusal message
refusal: String,
},
// Future: InputAudio, OutputAudio, etc.
}
impl ContentPart {
/// Create an input text part
pub fn input_text(text: impl Into<String>) -> Self {
Self::InputText { text: text.into() }
}
/// Create an output text part
pub fn output_text(text: impl Into<String>) -> Self {
Self::OutputText { text: text.into() }
}
/// Create a refusal part
pub fn refusal(refusal: impl Into<String>) -> Self {
Self::Refusal {
refusal: refusal.into(),
}
}
/// Get the text content regardless of type
pub fn as_text(&self) -> &str {
match self {
Self::InputText { text } => text,
Self::OutputText { text } => text,
Self::Refusal { refusal } => refusal,
}
}
}
// ============================================================================
// Role and Status
// ============================================================================
/// Message role
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// User
User,
/// Assistant
Assistant,
/// System (for system prompts, not typically used in items)
System,
}
/// Item status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemStatus {
/// Item is being generated
InProgress,
/// Item completed successfully
Completed,
/// Item was truncated (e.g., max tokens)
Incomplete,
}
// ============================================================================
// Request Types
// ============================================================================
/// LLM Request
#[derive(Debug, Clone, Default)]
pub struct Request {
/// System prompt (instructions)
pub system_prompt: Option<String>,
/// Input items (conversation history)
pub items: Vec<Item>,
/// Tool definitions
pub tools: Vec<ToolDefinition>,
/// Request configuration
pub config: RequestConfig,
}
impl Request {
/// Create a new empty request
pub fn new() -> Self {
Self::default()
}
/// Set the system prompt
pub fn system(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
/// Add a user message
pub fn user(mut self, content: impl Into<String>) -> Self {
self.items.push(Item::user_message(content));
self
}
/// Add an assistant message
pub fn assistant(mut self, content: impl Into<String>) -> Self {
self.items.push(Item::assistant_message(content));
self
}
/// Add an item
pub fn item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Add multiple items
pub fn items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
self.items.extend(items);
self
}
/// Add a tool definition
pub fn tool(mut self, tool: ToolDefinition) -> Self {
self.tools.push(tool);
self
}
/// Set the request config
pub fn config(mut self, config: RequestConfig) -> Self {
self.config = config;
self
}
/// Set max tokens
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
self.config.max_tokens = Some(max_tokens);
self
}
/// Set temperature
pub fn temperature(mut self, temperature: f32) -> Self {
self.config.temperature = Some(temperature);
self
}
/// Set top_p
pub fn top_p(mut self, top_p: f32) -> Self {
self.config.top_p = Some(top_p);
self
}
/// Set top_k
pub fn top_k(mut self, top_k: u32) -> Self {
self.config.top_k = Some(top_k);
self
}
/// Add a stop sequence
pub fn stop_sequence(mut self, sequence: impl Into<String>) -> Self {
self.config.stop_sequences.push(sequence.into());
self
}
}
// ============================================================================
// Tool Definition
// ============================================================================
/// Tool (function) definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
/// Tool name
pub name: String,
/// Tool description
pub description: Option<String>,
/// Input schema (JSON Schema)
pub input_schema: serde_json::Value,
}
impl ToolDefinition {
/// Create a new tool definition
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
}
}
/// Set the description
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
/// Set the input schema
pub fn input_schema(mut self, schema: serde_json::Value) -> Self {
self.input_schema = schema;
self
}
}
// ============================================================================
// Request Config
// ============================================================================
/// Request configuration
#[derive(Debug, Clone, Default)]
pub struct RequestConfig {
/// Maximum tokens to generate
pub max_tokens: Option<u32>,
/// Temperature (randomness)
pub temperature: Option<f32>,
/// Top P (nucleus sampling)
pub top_p: Option<f32>,
/// Top K
pub top_k: Option<u32>,
/// Stop sequences
pub stop_sequences: Vec<String>,
}
impl RequestConfig {
/// Create a new default config
pub fn new() -> Self {
Self::default()
}
/// Set max tokens
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = Some(max_tokens);
self
}
/// Set temperature
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
/// Set top_p
pub fn with_top_p(mut self, top_p: f32) -> Self {
self.top_p = Some(top_p);
self
}
/// Set top_k
pub fn with_top_k(mut self, top_k: u32) -> Self {
self.top_k = Some(top_k);
self
}
/// Add a stop sequence
pub fn with_stop_sequence(mut self, sequence: impl Into<String>) -> Self {
self.stop_sequences.push(sequence.into());
self
}
}

View File

@ -0,0 +1,16 @@
//! Message and Item Types
//!
//! This module provides the core types for representing conversation items
//! in the Open Responses format.
//!
//! The primary type is [`Item`], which represents different kinds of conversation
//! elements: messages, function calls, function call outputs, and reasoning.
// Re-export all types from llm_client::types
pub use crate::llm_client::types::{ContentPart, Item, Role};
/// Convenience alias for backward compatibility
///
/// In the Open Responses model, messages are just one type of Item.
/// This alias allows code that expects a "Message" type to continue working.
pub type Message = Item;

View File

@ -0,0 +1,60 @@
//! Worker State
//!
//! State marker types for cache protection using the Type-state pattern.
//! Worker has state transitions from `Mutable` → `CacheLocked`.
/// Marker trait representing Worker state
///
/// This trait is sealed and cannot be implemented externally.
pub trait WorkerState: private::Sealed + Send + Sync + 'static {}
mod private {
pub trait Sealed {}
}
/// Mutable state (editable)
///
/// In this state, the following operations are available:
/// - Setting/changing system prompt
/// - Editing message history (add, delete, clear)
/// - Registering tools and hooks
///
/// Can transition to [`CacheLocked`] state via `Worker::lock()`.
///
/// # Examples
///
/// ```ignore
/// use llm_worker::Worker;
///
/// let mut worker = Worker::new(client)
/// .system_prompt("You are helpful.");
///
/// // History can be edited
/// worker.push_message(Message::user("Hello"));
/// worker.clear_history();
///
/// // Lock to protected state
/// let locked = worker.lock();
/// ```
#[derive(Debug, Clone, Copy, Default)]
pub struct Mutable;
impl private::Sealed for Mutable {}
impl WorkerState for Mutable {}
/// Cache locked state (cache protected)
///
/// In this state, the following restrictions apply:
/// - System prompt cannot be changed
/// - Existing message history cannot be modified (only appending to the end)
///
/// To ensure LLM API KV cache hits,
/// using this state during execution is recommended.
///
/// Can return to [`Mutable`] state via `Worker::unlock()`,
/// but note that cache protection will be released.
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheLocked;
impl private::Sealed for CacheLocked {}
impl WorkerState for CacheLocked {}

View File

@ -0,0 +1,371 @@
//! Event Subscription
//!
//! Trait for receiving streaming events from LLM in real-time.
//! Used for stream display to UI and progress display.
use std::sync::{Arc, Mutex};
use crate::{
handler::{
ErrorKind, Handler, StatusKind, TextBlockEvent, TextBlockKind, ToolUseBlockEvent,
ToolUseBlockKind, UsageKind,
},
hook::ToolCall,
timeline::event::{ErrorEvent, StatusEvent, UsageEvent},
};
// =============================================================================
// WorkerSubscriber Trait
// =============================================================================
/// Trait for subscribing to streaming events from LLM
///
/// When registered with Worker, you can receive events from text generation
/// and tool calls in real-time. Ideal for stream display to UI.
///
/// # Available Events
///
/// - **Block events**: Text, tool use (with scope)
/// - **Meta events**: Usage, status, error
/// - **Completion events**: Text complete, tool call complete
/// - **Turn control**: Turn start, turn end
///
/// # Examples
///
/// ```ignore
/// use llm_worker::subscriber::WorkerSubscriber;
/// use llm_worker::timeline::TextBlockEvent;
///
/// struct StreamPrinter;
///
/// impl WorkerSubscriber for StreamPrinter {
/// type TextBlockScope = ();
/// type ToolUseBlockScope = ();
///
/// fn on_text_block(&mut self, _: &mut (), event: &TextBlockEvent) {
/// if let TextBlockEvent::Delta(text) = event {
/// print!("{}", text); // Real-time output
/// }
/// }
///
/// fn on_text_complete(&mut self, text: &str) {
/// println!("\n--- Complete: {} chars ---", text.len());
/// }
/// }
///
/// // Register with Worker
/// worker.subscribe(StreamPrinter);
/// ```
pub trait WorkerSubscriber: Send {
// =========================================================================
// Scope Types (for block events)
// =========================================================================
/// Scope type for text block processing
///
/// Generated with Default::default() at block start,
/// destroyed at block end.
type TextBlockScope: Default + Send + Sync;
/// Scope type for tool use block processing
type ToolUseBlockScope: Default + Send + Sync;
// =========================================================================
// Block Events (with scope management)
// =========================================================================
/// Text block event
///
/// Has Start/Delta/Stop lifecycle.
/// Scope is generated at block start and destroyed at end.
#[allow(unused_variables)]
fn on_text_block(&mut self, scope: &mut Self::TextBlockScope, event: &TextBlockEvent) {}
/// Tool use block event
///
/// Has Start/InputJsonDelta/Stop lifecycle.
#[allow(unused_variables)]
fn on_tool_use_block(
&mut self,
scope: &mut Self::ToolUseBlockScope,
event: &ToolUseBlockEvent,
) {
}
// =========================================================================
// Single Events (no scope needed)
// =========================================================================
/// Usage event
#[allow(unused_variables)]
fn on_usage(&mut self, event: &UsageEvent) {}
/// Status event
#[allow(unused_variables)]
fn on_status(&mut self, event: &StatusEvent) {}
/// Error event
#[allow(unused_variables)]
fn on_error(&mut self, event: &ErrorEvent) {}
// =========================================================================
// Accumulated Events (added in Worker layer)
// =========================================================================
/// Text complete event
///
/// When a text block completes, the entire accumulated text is passed.
/// Convenient for receiving the final result after block processing.
#[allow(unused_variables)]
fn on_text_complete(&mut self, text: &str) {}
/// Tool call complete event
///
/// When a tool use block completes, the complete ToolCall is passed.
#[allow(unused_variables)]
fn on_tool_call_complete(&mut self, call: &ToolCall) {}
// =========================================================================
// Turn Control
// =========================================================================
/// On turn start
///
/// `turn` is a 0-based turn number.
#[allow(unused_variables)]
fn on_turn_start(&mut self, turn: usize) {}
/// On turn end
#[allow(unused_variables)]
fn on_turn_end(&mut self, turn: usize) {}
}
// =============================================================================
// SubscriberAdapter - Bridge WorkerSubscriber to Timeline handlers
// =============================================================================
// =============================================================================
// TextBlock Handler Adapter
// =============================================================================
/// Subscriber adapter for TextBlockKind
pub(crate) struct TextBlockSubscriberAdapter<S: WorkerSubscriber> {
subscriber: Arc<Mutex<S>>,
}
impl<S: WorkerSubscriber> TextBlockSubscriberAdapter<S> {
pub fn new(subscriber: Arc<Mutex<S>>) -> Self {
Self { subscriber }
}
}
impl<S: WorkerSubscriber> Clone for TextBlockSubscriberAdapter<S> {
fn clone(&self) -> Self {
Self {
subscriber: self.subscriber.clone(),
}
}
}
/// Wrapper for TextBlock scope
pub struct TextBlockScopeWrapper<S: WorkerSubscriber> {
inner: S::TextBlockScope,
buffer: String, // Buffer for on_text_complete
}
impl<S: WorkerSubscriber> Default for TextBlockScopeWrapper<S> {
fn default() -> Self {
Self {
inner: S::TextBlockScope::default(),
buffer: String::new(),
}
}
}
impl<S: WorkerSubscriber + 'static> Handler<TextBlockKind> for TextBlockSubscriberAdapter<S> {
type Scope = TextBlockScopeWrapper<S>;
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
// Accumulate deltas into buffer
if let TextBlockEvent::Delta(text) = event {
scope.buffer.push_str(text);
}
// Call Subscriber's TextBlock event handler
if let Ok(mut subscriber) = self.subscriber.lock() {
subscriber.on_text_block(&mut scope.inner, event);
// Also call on_text_complete on Stop
if matches!(event, TextBlockEvent::Stop(_)) {
subscriber.on_text_complete(&scope.buffer);
}
}
}
}
// =============================================================================
// ToolUseBlock Handler Adapter
// =============================================================================
/// Subscriber adapter for ToolUseBlockKind
pub(crate) struct ToolUseBlockSubscriberAdapter<S: WorkerSubscriber> {
subscriber: Arc<Mutex<S>>,
}
impl<S: WorkerSubscriber> ToolUseBlockSubscriberAdapter<S> {
pub fn new(subscriber: Arc<Mutex<S>>) -> Self {
Self { subscriber }
}
}
impl<S: WorkerSubscriber> Clone for ToolUseBlockSubscriberAdapter<S> {
fn clone(&self) -> Self {
Self {
subscriber: self.subscriber.clone(),
}
}
}
/// Wrapper for ToolUseBlock scope
pub struct ToolUseBlockScopeWrapper<S: WorkerSubscriber> {
inner: S::ToolUseBlockScope,
id: String,
name: String,
input_json: String, // JSON accumulation
}
impl<S: WorkerSubscriber> Default for ToolUseBlockScopeWrapper<S> {
fn default() -> Self {
Self {
inner: S::ToolUseBlockScope::default(),
id: String::new(),
name: String::new(),
input_json: String::new(),
}
}
}
impl<S: WorkerSubscriber + 'static> Handler<ToolUseBlockKind> for ToolUseBlockSubscriberAdapter<S> {
type Scope = ToolUseBlockScopeWrapper<S>;
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
// Save metadata on Start
if let ToolUseBlockEvent::Start(start) = event {
scope.id = start.id.clone();
scope.name = start.name.clone();
}
// Accumulate InputJsonDelta into buffer
if let ToolUseBlockEvent::InputJsonDelta(json) = event {
scope.input_json.push_str(json);
}
// Call Subscriber's ToolUseBlock event handler
if let Ok(mut subscriber) = self.subscriber.lock() {
subscriber.on_tool_use_block(&mut scope.inner, event);
// Also call on_tool_call_complete on Stop
if matches!(event, ToolUseBlockEvent::Stop(_)) {
let input: serde_json::Value =
serde_json::from_str(&scope.input_json).unwrap_or_default();
let tool_call = ToolCall {
id: scope.id.clone(),
name: scope.name.clone(),
input,
};
subscriber.on_tool_call_complete(&tool_call);
}
}
}
}
// =============================================================================
// Meta Event Handler Adapters
// =============================================================================
/// Subscriber adapter for UsageKind
pub(crate) struct UsageSubscriberAdapter<S: WorkerSubscriber> {
subscriber: Arc<Mutex<S>>,
}
impl<S: WorkerSubscriber> UsageSubscriberAdapter<S> {
pub fn new(subscriber: Arc<Mutex<S>>) -> Self {
Self { subscriber }
}
}
impl<S: WorkerSubscriber> Clone for UsageSubscriberAdapter<S> {
fn clone(&self) -> Self {
Self {
subscriber: self.subscriber.clone(),
}
}
}
impl<S: WorkerSubscriber + 'static> Handler<UsageKind> for UsageSubscriberAdapter<S> {
type Scope = ();
fn on_event(&mut self, _scope: &mut Self::Scope, event: &UsageEvent) {
if let Ok(mut subscriber) = self.subscriber.lock() {
subscriber.on_usage(event);
}
}
}
/// Subscriber adapter for StatusKind
pub(crate) struct StatusSubscriberAdapter<S: WorkerSubscriber> {
subscriber: Arc<Mutex<S>>,
}
impl<S: WorkerSubscriber> StatusSubscriberAdapter<S> {
pub fn new(subscriber: Arc<Mutex<S>>) -> Self {
Self { subscriber }
}
}
impl<S: WorkerSubscriber> Clone for StatusSubscriberAdapter<S> {
fn clone(&self) -> Self {
Self {
subscriber: self.subscriber.clone(),
}
}
}
impl<S: WorkerSubscriber + 'static> Handler<StatusKind> for StatusSubscriberAdapter<S> {
type Scope = ();
fn on_event(&mut self, _scope: &mut Self::Scope, event: &StatusEvent) {
if let Ok(mut subscriber) = self.subscriber.lock() {
subscriber.on_status(event);
}
}
}
/// Subscriber adapter for ErrorKind
pub(crate) struct ErrorSubscriberAdapter<S: WorkerSubscriber> {
subscriber: Arc<Mutex<S>>,
}
impl<S: WorkerSubscriber> ErrorSubscriberAdapter<S> {
pub fn new(subscriber: Arc<Mutex<S>>) -> Self {
Self { subscriber }
}
}
impl<S: WorkerSubscriber> Clone for ErrorSubscriberAdapter<S> {
fn clone(&self) -> Self {
Self {
subscriber: self.subscriber.clone(),
}
}
}
impl<S: WorkerSubscriber + 'static> Handler<ErrorKind> for ErrorSubscriberAdapter<S> {
type Scope = ();
fn on_event(&mut self, _scope: &mut Self::Scope, event: &ErrorEvent) {
if let Ok(mut subscriber) = self.subscriber.lock() {
subscriber.on_error(event);
}
}
}

View File

@ -0,0 +1,448 @@
//! Timeline層のイベント型
//!
//! Timelineが受け取り、各Handlerへディスパッチするイベント表現。
use serde::{Deserialize, Serialize};
// =============================================================================
// Core Event Types (from llm_client layer)
// =============================================================================
/// LLMからのストリーミングイベント
///
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
/// 統一的に処理されます。
///
/// # イベントの種類
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
///
/// # ブロックのライフサイクル
///
/// テキストやツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
/// の順序でイベントが発生します。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Event {
/// ハートビート
Ping(PingEvent),
/// トークン使用量
Usage(UsageEvent),
/// ストリームのステータス変化
Status(StatusEvent),
/// エラー発生
Error(ErrorEvent),
/// ブロック開始(テキスト、ツール使用等)
BlockStart(BlockStart),
/// ブロックの差分データ
BlockDelta(BlockDelta),
/// ブロック正常終了
BlockStop(BlockStop),
/// ブロック中断
BlockAbort(BlockAbort),
}
// =============================================================================
// Meta Events
// =============================================================================
/// Pingイベントハートビート
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PingEvent {
pub timestamp: Option<u64>,
}
/// 使用量イベント
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct UsageEvent {
/// 入力トークン数
pub input_tokens: Option<u64>,
/// 出力トークン数
pub output_tokens: Option<u64>,
/// 合計トークン数
pub total_tokens: Option<u64>,
/// キャッシュ読み込みトークン数
pub cache_read_input_tokens: Option<u64>,
/// キャッシュ作成トークン数
pub cache_creation_input_tokens: Option<u64>,
}
/// ステータスイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusEvent {
pub status: ResponseStatus,
}
/// レスポンスステータス
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ResponseStatus {
/// ストリーム開始
Started,
/// 正常完了
Completed,
/// キャンセルされた
Cancelled,
/// エラー発生
Failed,
}
/// エラーイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ErrorEvent {
pub code: Option<String>,
pub message: String,
}
// =============================================================================
// Block Types
// =============================================================================
/// ブロックの種別
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockType {
/// テキスト生成
Text,
/// 思考 (Claude Extended Thinking等)
Thinking,
/// ツール呼び出し
ToolUse,
/// ツール結果
ToolResult,
}
/// ブロック開始イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStart {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// ブロック固有のメタデータ
pub metadata: BlockMetadata,
}
impl BlockStart {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロックのメタデータ
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockMetadata {
Text,
Thinking,
ToolUse { id: String, name: String },
ToolResult { tool_use_id: String },
}
/// ブロックデルタイベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockDelta {
/// ブロックのインデックス
pub index: usize,
/// デルタの内容
pub delta: DeltaContent,
}
/// デルタの内容
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeltaContent {
/// テキストデルタ
Text(String),
/// 思考デルタ
Thinking(String),
/// ツール引数のJSON部分文字列
InputJson(String),
}
impl DeltaContent {
/// デルタのブロック種別を取得
pub fn block_type(&self) -> BlockType {
match self {
DeltaContent::Text(_) => BlockType::Text,
DeltaContent::Thinking(_) => BlockType::Thinking,
DeltaContent::InputJson(_) => BlockType::ToolUse,
}
}
}
/// ブロック停止イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockStop {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 停止理由
pub stop_reason: Option<StopReason>,
}
impl BlockStop {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// ブロック中断イベント
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlockAbort {
/// ブロックのインデックス
pub index: usize,
/// ブロックの種別
pub block_type: BlockType,
/// 中断理由
pub reason: String,
}
impl BlockAbort {
pub fn block_type(&self) -> BlockType {
self.block_type
}
}
/// 停止理由
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {
/// 自然終了
EndTurn,
/// 最大トークン数到達
MaxTokens,
/// ストップシーケンス到達
StopSequence,
/// ツール使用
ToolUse,
}
// =============================================================================
// Builder / Factory helpers
// =============================================================================
impl Event {
/// テキストブロック開始イベントを作成
pub fn text_block_start(index: usize) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::Text,
metadata: BlockMetadata::Text,
})
}
/// テキストデルタイベントを作成
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::Text(text.into()),
})
}
/// テキストブロック停止イベントを作成
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::Text,
stop_reason,
})
}
/// ツール使用ブロック開始イベントを作成
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Event::BlockStart(BlockStart {
index,
block_type: BlockType::ToolUse,
metadata: BlockMetadata::ToolUse {
id: id.into(),
name: name.into(),
},
})
}
/// ツール引数デルタイベントを作成
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
Event::BlockDelta(BlockDelta {
index,
delta: DeltaContent::InputJson(json.into()),
})
}
/// ツール使用ブロック停止イベントを作成
pub fn tool_use_stop(index: usize) -> Self {
Event::BlockStop(BlockStop {
index,
block_type: BlockType::ToolUse,
stop_reason: Some(StopReason::ToolUse),
})
}
/// 使用量イベントを作成
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
Event::Usage(UsageEvent {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
total_tokens: Some(input_tokens + output_tokens),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
}
/// Pingイベントを作成
pub fn ping() -> Self {
Event::Ping(PingEvent { timestamp: None })
}
}
// =============================================================================
// Conversions: llm_client::event -> timeline::event
// =============================================================================
impl From<crate::llm_client::event::ResponseStatus> for ResponseStatus {
fn from(value: crate::llm_client::event::ResponseStatus) -> Self {
match value {
crate::llm_client::event::ResponseStatus::Started => ResponseStatus::Started,
crate::llm_client::event::ResponseStatus::Completed => ResponseStatus::Completed,
crate::llm_client::event::ResponseStatus::Cancelled => ResponseStatus::Cancelled,
crate::llm_client::event::ResponseStatus::Failed => ResponseStatus::Failed,
}
}
}
impl From<crate::llm_client::event::BlockType> for BlockType {
fn from(value: crate::llm_client::event::BlockType) -> Self {
match value {
crate::llm_client::event::BlockType::Text => BlockType::Text,
crate::llm_client::event::BlockType::Thinking => BlockType::Thinking,
crate::llm_client::event::BlockType::ToolUse => BlockType::ToolUse,
crate::llm_client::event::BlockType::ToolResult => BlockType::ToolResult,
}
}
}
impl From<crate::llm_client::event::BlockMetadata> for BlockMetadata {
fn from(value: crate::llm_client::event::BlockMetadata) -> Self {
match value {
crate::llm_client::event::BlockMetadata::Text => BlockMetadata::Text,
crate::llm_client::event::BlockMetadata::Thinking => BlockMetadata::Thinking,
crate::llm_client::event::BlockMetadata::ToolUse { id, name } => {
BlockMetadata::ToolUse { id, name }
}
crate::llm_client::event::BlockMetadata::ToolResult { tool_use_id } => {
BlockMetadata::ToolResult { tool_use_id }
}
}
}
}
impl From<crate::llm_client::event::DeltaContent> for DeltaContent {
fn from(value: crate::llm_client::event::DeltaContent) -> Self {
match value {
crate::llm_client::event::DeltaContent::Text(text) => DeltaContent::Text(text),
crate::llm_client::event::DeltaContent::Thinking(text) => DeltaContent::Thinking(text),
crate::llm_client::event::DeltaContent::InputJson(json) => {
DeltaContent::InputJson(json)
}
}
}
}
impl From<crate::llm_client::event::StopReason> for StopReason {
fn from(value: crate::llm_client::event::StopReason) -> Self {
match value {
crate::llm_client::event::StopReason::EndTurn => StopReason::EndTurn,
crate::llm_client::event::StopReason::MaxTokens => StopReason::MaxTokens,
crate::llm_client::event::StopReason::StopSequence => StopReason::StopSequence,
crate::llm_client::event::StopReason::ToolUse => StopReason::ToolUse,
}
}
}
impl From<crate::llm_client::event::PingEvent> for PingEvent {
fn from(value: crate::llm_client::event::PingEvent) -> Self {
PingEvent {
timestamp: value.timestamp,
}
}
}
impl From<crate::llm_client::event::UsageEvent> for UsageEvent {
fn from(value: crate::llm_client::event::UsageEvent) -> Self {
UsageEvent {
input_tokens: value.input_tokens,
output_tokens: value.output_tokens,
total_tokens: value.total_tokens,
cache_read_input_tokens: value.cache_read_input_tokens,
cache_creation_input_tokens: value.cache_creation_input_tokens,
}
}
}
impl From<crate::llm_client::event::StatusEvent> for StatusEvent {
fn from(value: crate::llm_client::event::StatusEvent) -> Self {
StatusEvent {
status: value.status.into(),
}
}
}
impl From<crate::llm_client::event::ErrorEvent> for ErrorEvent {
fn from(value: crate::llm_client::event::ErrorEvent) -> Self {
ErrorEvent {
code: value.code,
message: value.message,
}
}
}
impl From<crate::llm_client::event::BlockStart> for BlockStart {
fn from(value: crate::llm_client::event::BlockStart) -> Self {
BlockStart {
index: value.index,
block_type: value.block_type.into(),
metadata: value.metadata.into(),
}
}
}
impl From<crate::llm_client::event::BlockDelta> for BlockDelta {
fn from(value: crate::llm_client::event::BlockDelta) -> Self {
BlockDelta {
index: value.index,
delta: value.delta.into(),
}
}
}
impl From<crate::llm_client::event::BlockStop> for BlockStop {
fn from(value: crate::llm_client::event::BlockStop) -> Self {
BlockStop {
index: value.index,
block_type: value.block_type.into(),
stop_reason: value.stop_reason.map(Into::into),
}
}
}
impl From<crate::llm_client::event::BlockAbort> for BlockAbort {
fn from(value: crate::llm_client::event::BlockAbort) -> Self {
BlockAbort {
index: value.index,
block_type: value.block_type.into(),
reason: value.reason,
}
}
}
impl From<crate::llm_client::event::Event> for Event {
fn from(value: crate::llm_client::event::Event) -> Self {
match value {
crate::llm_client::event::Event::Ping(p) => Event::Ping(p.into()),
crate::llm_client::event::Event::Usage(u) => Event::Usage(u.into()),
crate::llm_client::event::Event::Status(s) => Event::Status(s.into()),
crate::llm_client::event::Event::Error(e) => Event::Error(e.into()),
crate::llm_client::event::Event::BlockStart(s) => Event::BlockStart(s.into()),
crate::llm_client::event::Event::BlockDelta(d) => Event::BlockDelta(d.into()),
crate::llm_client::event::Event::BlockStop(s) => Event::BlockStop(s.into()),
crate::llm_client::event::Event::BlockAbort(a) => Event::BlockAbort(a.into()),
}
}
}

View File

@ -0,0 +1,47 @@
//! Timeline層
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//!
//! # 主要コンポーネント
//!
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
//! - [`Handler`] - イベントを処理するトレイト
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
pub mod event;
mod text_block_collector;
mod timeline;
mod tool_call_collector;
// 公開API
pub use event::*;
pub use text_block_collector::TextBlockCollector;
pub use timeline::{ErasedHandler, HandlerWrapper, Timeline};
pub use tool_call_collector::ToolCallCollector;
// 型定義からのre-export
pub use crate::handler::{
// Meta Kinds
ErrorKind,
// Core traits
Handler,
Kind,
PingKind,
StatusKind,
// Block Events
TextBlockEvent,
// Block Kinds
TextBlockKind,
TextBlockStart,
TextBlockStop,
ThinkingBlockEvent,
ThinkingBlockKind,
ThinkingBlockStart,
ThinkingBlockStop,
ToolUseBlockEvent,
ToolUseBlockKind,
ToolUseBlockStart,
ToolUseBlockStop,
UsageKind,
};

View File

@ -0,0 +1,131 @@
//! TextBlockCollector - テキストブロック収集用ハンドラ
//!
//! TimelineのTextBlockHandler として登録され、
//! ストリーム中のテキストブロックを収集する。
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
use std::sync::{Arc, Mutex};
/// TextBlockから収集したテキスト情報を保持
#[derive(Debug, Default)]
pub struct TextCollectorState {
/// 蓄積中のテキスト
buffer: String,
}
/// TextBlockCollector - テキストブロックハンドラ
///
/// Timelineに登録してTextBlockイベントを受信し、
/// 完了したテキストブロックを収集する。
#[derive(Clone)]
pub struct TextBlockCollector {
/// 収集されたテキストブロック
collected: Arc<Mutex<Vec<String>>>,
}
impl TextBlockCollector {
/// 新しいTextBlockCollectorを作成
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
}
}
/// 収集されたテキストを取得してクリア
pub fn take_collected(&self) -> Vec<String> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集されたテキストの参照を取得
pub fn collected(&self) -> Vec<String> {
self.collected.lock().unwrap().clone()
}
/// 収集されたテキストがあるかどうか
pub fn has_content(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
}
impl Default for TextBlockCollector {
fn default() -> Self {
Self::new()
}
}
impl Handler<TextBlockKind> for TextBlockCollector {
type Scope = TextCollectorState;
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {
scope.buffer.clear();
}
TextBlockEvent::Delta(text) => {
scope.buffer.push_str(text);
}
TextBlockEvent::Stop(_) => {
// ブロック完了時にテキストを確定
if !scope.buffer.is_empty() {
let text = std::mem::take(&mut scope.buffer);
self.collected.lock().unwrap().push(text);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::Timeline;
use crate::timeline::event::Event;
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
#[test]
fn test_collect_single_text_block() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// テキストブロックのイベントシーケンスをディスパッチ
timeline.dispatch(&Event::text_block_start(0));
timeline.dispatch(&Event::text_delta(0, "Hello, "));
timeline.dispatch(&Event::text_delta(0, "World!"));
timeline.dispatch(&Event::text_block_stop(0, None));
// 収集されたテキストを確認
let texts = collector.take_collected();
assert_eq!(texts.len(), 1);
assert_eq!(texts[0], "Hello, World!");
}
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
#[test]
fn test_collect_multiple_text_blocks() {
let collector = TextBlockCollector::new();
let mut timeline = Timeline::new();
timeline.on_text_block(collector.clone());
// 1つ目のテキストブロック
timeline.dispatch(&Event::text_block_start(0));
timeline.dispatch(&Event::text_delta(0, "First"));
timeline.dispatch(&Event::text_block_stop(0, None));
// 2つ目のテキストブロック
timeline.dispatch(&Event::text_block_start(1));
timeline.dispatch(&Event::text_delta(1, "Second"));
timeline.dispatch(&Event::text_block_stop(1, None));
let texts = collector.take_collected();
assert_eq!(texts.len(), 2);
assert_eq!(texts[0], "First");
assert_eq!(texts[1], "Second");
}
}

View File

@ -0,0 +1,637 @@
//! Timeline層
//!
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
//! 通常はWorker経由で使用しますが、直接使用することも可能です。
use std::marker::PhantomData;
use super::event::*;
use crate::handler::*;
// =============================================================================
// Type-erased Handler
// =============================================================================
/// 型消去された`Handler` trait
///
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
/// 自動的にラップされます。
pub trait ErasedHandler<K: Kind>: Send + Sync {
/// イベントをディスパッチ
fn dispatch(&mut self, event: &K::Event);
/// スコープを開始Block開始時
fn start_scope(&mut self);
/// スコープを終了Block終了時
fn end_scope(&mut self);
}
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
pub struct HandlerWrapper<H, K>
where
H: Handler<K>,
K: Kind,
{
handler: H,
scope: Option<H::Scope>,
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
_kind: PhantomData<fn() -> K>,
}
impl<H, K> HandlerWrapper<H, K>
where
H: Handler<K>,
K: Kind,
{
pub fn new(handler: H) -> Self {
Self {
handler,
scope: None,
_kind: PhantomData,
}
}
}
impl<H, K> ErasedHandler<K> for HandlerWrapper<H, K>
where
H: Handler<K> + Send + Sync,
K: Kind,
H::Scope: Send + Sync,
{
fn dispatch(&mut self, event: &K::Event) {
if let Some(scope) = &mut self.scope {
self.handler.on_event(scope, event);
}
}
fn start_scope(&mut self) {
self.scope = Some(H::Scope::default());
}
fn end_scope(&mut self) {
self.scope = None;
}
}
// =============================================================================
// Block Handler Registry
// =============================================================================
/// ブロックハンドラーの型消去trait
trait ErasedBlockHandler: Send + Sync {
fn dispatch_start(&mut self, start: &BlockStart);
fn dispatch_delta(&mut self, delta: &BlockDelta);
fn dispatch_stop(&mut self, stop: &BlockStop);
fn dispatch_abort(&mut self, abort: &BlockAbort);
fn start_scope(&mut self);
fn end_scope(&mut self);
/// スコープがアクティブかどうか
fn has_scope(&self) -> bool;
}
/// TextBlockKind用のラッパー
struct TextBlockHandlerWrapper<H>
where
H: Handler<TextBlockKind>,
{
handler: H,
scope: Option<H::Scope>,
}
impl<H> TextBlockHandlerWrapper<H>
where
H: Handler<TextBlockKind>,
{
fn new(handler: H) -> Self {
Self {
handler,
scope: None,
}
}
}
impl<H> ErasedBlockHandler for TextBlockHandlerWrapper<H>
where
H: Handler<TextBlockKind> + Send + Sync,
H::Scope: Send + Sync,
{
fn dispatch_start(&mut self, start: &BlockStart) {
if let Some(scope) = &mut self.scope {
self.handler.on_event(
scope,
&TextBlockEvent::Start(TextBlockStart { index: start.index }),
);
}
}
fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope {
if let DeltaContent::Text(text) = &delta.delta {
self.handler
.on_event(scope, &TextBlockEvent::Delta(text.clone()));
}
}
}
fn dispatch_stop(&mut self, stop: &BlockStop) {
if let Some(scope) = &mut self.scope {
self.handler.on_event(
scope,
&TextBlockEvent::Stop(TextBlockStop {
index: stop.index,
stop_reason: stop.stop_reason.clone(),
}),
);
}
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
// TextBlockはabortを特別扱いしないスコープ終了のみ
}
fn start_scope(&mut self) {
self.scope = Some(H::Scope::default());
}
fn end_scope(&mut self) {
self.scope = None;
}
fn has_scope(&self) -> bool {
self.scope.is_some()
}
}
/// ThinkingBlockKind用のラッパー
struct ThinkingBlockHandlerWrapper<H>
where
H: Handler<ThinkingBlockKind>,
{
handler: H,
scope: Option<H::Scope>,
}
impl<H> ThinkingBlockHandlerWrapper<H>
where
H: Handler<ThinkingBlockKind>,
{
fn new(handler: H) -> Self {
Self {
handler,
scope: None,
}
}
}
impl<H> ErasedBlockHandler for ThinkingBlockHandlerWrapper<H>
where
H: Handler<ThinkingBlockKind> + Send + Sync,
H::Scope: Send + Sync,
{
fn dispatch_start(&mut self, start: &BlockStart) {
if let Some(scope) = &mut self.scope {
self.handler.on_event(
scope,
&ThinkingBlockEvent::Start(ThinkingBlockStart { index: start.index }),
);
}
}
fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope {
if let DeltaContent::Thinking(text) = &delta.delta {
self.handler
.on_event(scope, &ThinkingBlockEvent::Delta(text.clone()));
}
}
}
fn dispatch_stop(&mut self, stop: &BlockStop) {
if let Some(scope) = &mut self.scope {
self.handler.on_event(
scope,
&ThinkingBlockEvent::Stop(ThinkingBlockStop { index: stop.index }),
);
}
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {}
fn start_scope(&mut self) {
self.scope = Some(H::Scope::default());
}
fn end_scope(&mut self) {
self.scope = None;
}
fn has_scope(&self) -> bool {
self.scope.is_some()
}
}
/// ToolUseBlockKind用のラッパー
struct ToolUseBlockHandlerWrapper<H>
where
H: Handler<ToolUseBlockKind>,
{
handler: H,
scope: Option<H::Scope>,
current_tool: Option<(String, String)>, // (id, name)
}
impl<H> ToolUseBlockHandlerWrapper<H>
where
H: Handler<ToolUseBlockKind>,
{
fn new(handler: H) -> Self {
Self {
handler,
scope: None,
current_tool: None,
}
}
}
impl<H> ErasedBlockHandler for ToolUseBlockHandlerWrapper<H>
where
H: Handler<ToolUseBlockKind> + Send + Sync,
H::Scope: Send + Sync,
{
fn dispatch_start(&mut self, start: &BlockStart) {
if let Some(scope) = &mut self.scope {
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
self.current_tool = Some((id.clone(), name.clone()));
self.handler.on_event(
scope,
&ToolUseBlockEvent::Start(ToolUseBlockStart {
index: start.index,
id: id.clone(),
name: name.clone(),
}),
);
}
}
}
fn dispatch_delta(&mut self, delta: &BlockDelta) {
if let Some(scope) = &mut self.scope {
if let DeltaContent::InputJson(json) = &delta.delta {
self.handler
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
}
}
}
fn dispatch_stop(&mut self, stop: &BlockStop) {
if let Some(scope) = &mut self.scope {
if let Some((id, name)) = self.current_tool.take() {
self.handler.on_event(
scope,
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
index: stop.index,
id,
name,
}),
);
}
}
}
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
self.current_tool = None;
}
fn start_scope(&mut self) {
self.scope = Some(H::Scope::default());
}
fn end_scope(&mut self) {
self.scope = None;
self.current_tool = None;
}
fn has_scope(&self) -> bool {
self.scope.is_some()
}
}
// =============================================================================
// Timeline
// =============================================================================
/// イベントストリームの管理とハンドラへのディスパッチ
///
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
/// ブロック系イベントはスコープ管理付きで処理されます。
///
/// # Examples
///
/// ```ignore
/// use llm_worker::{Timeline, Handler, TextBlockKind, TextBlockEvent};
///
/// struct MyHandler;
/// impl Handler<TextBlockKind> for MyHandler {
/// type Scope = String;
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
/// if let TextBlockEvent::Delta(text) = event {
/// buffer.push_str(text);
/// }
/// }
/// }
///
/// let mut timeline = Timeline::new();
/// timeline.on_text_block(MyHandler);
/// ```
///
/// # サポートするイベント種別
///
/// - **メタ系**: Usage, Ping, Status, Error
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
pub struct Timeline {
// Meta系ハンドラー
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
// Block系ハンドラーBlockTypeごとにグループ化
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
// 現在アクティブなブロック
current_block: Option<BlockType>,
}
impl Default for Timeline {
fn default() -> Self {
Self::new()
}
}
impl Timeline {
pub fn new() -> Self {
Self {
usage_handlers: Vec::new(),
ping_handlers: Vec::new(),
status_handlers: Vec::new(),
error_handlers: Vec::new(),
text_block_handlers: Vec::new(),
thinking_block_handlers: Vec::new(),
tool_use_block_handlers: Vec::new(),
current_block: None,
}
}
// =========================================================================
// Handler Registration
// =========================================================================
/// UsageKind用のHandlerを登録
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<UsageKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
// Meta系はデフォルトでスコープを開始しておく
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.usage_handlers.push(Box::new(wrapper));
self
}
/// PingKind用のHandlerを登録
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<PingKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.ping_handlers.push(Box::new(wrapper));
self
}
/// StatusKind用のHandlerを登録
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<StatusKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.status_handlers.push(Box::new(wrapper));
self
}
/// ErrorKind用のHandlerを登録
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ErrorKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.error_handlers.push(Box::new(wrapper));
self
}
/// TextBlockKind用のHandlerを登録
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<TextBlockKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
self.text_block_handlers
.push(Box::new(TextBlockHandlerWrapper::new(handler)));
self
}
/// ThinkingBlockKind用のHandlerを登録
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
self.thinking_block_handlers
.push(Box::new(ThinkingBlockHandlerWrapper::new(handler)));
self
}
/// ToolUseBlockKind用のHandlerを登録
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
self.tool_use_block_handlers
.push(Box::new(ToolUseBlockHandlerWrapper::new(handler)));
self
}
// =========================================================================
// Event Dispatch
// =========================================================================
/// メインのディスパッチエントリポイント
pub fn dispatch(&mut self, event: &Event) {
match event {
// Meta系: 即時ディスパッチ(登録順)
Event::Usage(u) => self.dispatch_usage(u),
Event::Ping(p) => self.dispatch_ping(p),
Event::Status(s) => self.dispatch_status(s),
Event::Error(e) => self.dispatch_error(e),
// Block系: スコープ管理しながらディスパッチ
Event::BlockStart(s) => self.handle_block_start(s),
Event::BlockDelta(d) => self.handle_block_delta(d),
Event::BlockStop(s) => self.handle_block_stop(s),
Event::BlockAbort(a) => self.handle_block_abort(a),
}
}
fn dispatch_usage(&mut self, event: &UsageEvent) {
for handler in &mut self.usage_handlers {
handler.dispatch(event);
}
}
fn dispatch_ping(&mut self, event: &PingEvent) {
for handler in &mut self.ping_handlers {
handler.dispatch(event);
}
}
fn dispatch_status(&mut self, event: &StatusEvent) {
for handler in &mut self.status_handlers {
handler.dispatch(event);
}
}
fn dispatch_error(&mut self, event: &ErrorEvent) {
for handler in &mut self.error_handlers {
handler.dispatch(event);
}
}
fn handle_block_start(&mut self, start: &BlockStart) {
self.current_block = Some(start.block_type);
let handlers = self.get_block_handlers_mut(start.block_type);
for handler in handlers {
handler.start_scope();
handler.dispatch_start(start);
}
}
fn handle_block_delta(&mut self, delta: &BlockDelta) {
let block_type = delta.delta.block_type();
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
// Deltaが来たときにスコープがなければ暗黙的に開始する
if self.current_block.is_none() {
self.current_block = Some(block_type);
}
let handlers = self.get_block_handlers_mut(block_type);
for handler in handlers {
// スコープがなければ暗黙的に開始
if !handler.has_scope() {
handler.start_scope();
}
handler.dispatch_delta(delta);
}
}
fn handle_block_stop(&mut self, stop: &BlockStop) {
let handlers = self.get_block_handlers_mut(stop.block_type);
for handler in handlers {
handler.dispatch_stop(stop);
handler.end_scope();
}
self.current_block = None;
}
fn handle_block_abort(&mut self, abort: &BlockAbort) {
let handlers = self.get_block_handlers_mut(abort.block_type);
for handler in handlers {
handler.dispatch_abort(abort);
handler.end_scope();
}
self.current_block = None;
}
fn get_block_handlers_mut(
&mut self,
block_type: BlockType,
) -> &mut Vec<Box<dyn ErasedBlockHandler>> {
match block_type {
BlockType::Text => &mut self.text_block_handlers,
BlockType::Thinking => &mut self.thinking_block_handlers,
BlockType::ToolUse => &mut self.tool_use_block_handlers,
BlockType::ToolResult => &mut self.text_block_handlers, // ToolResultはTextとして扱う
}
}
/// 現在アクティブなブロックタイプを取得
pub fn current_block(&self) -> Option<BlockType> {
self.current_block
}
/// 現在アクティブなブロックを中断する
///
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
/// BlockAbortイベントを発火してスコープをクリーンアップする。
pub fn abort_current_block(&mut self) {
if let Some(block_type) = self.current_block {
let abort = crate::timeline::event::BlockAbort {
index: 0, // インデックスは不明なので0
block_type,
reason: "Cancelled".to_string(),
};
self.handle_block_abort(&abort);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn test_timeline_creation() {
let timeline = Timeline::new();
assert!(timeline.current_block().is_none());
}
#[test]
fn test_meta_event_dispatch() {
// シンプルなテスト用構造体
struct TestUsageHandler {
calls: Arc<Mutex<Vec<UsageEvent>>>,
}
impl Handler<UsageKind> for TestUsageHandler {
type Scope = ();
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
self.calls.lock().unwrap().push(event.clone());
}
}
let calls = Arc::new(Mutex::new(Vec::new()));
let handler = TestUsageHandler {
calls: calls.clone(),
};
let mut timeline = Timeline::new();
timeline.on_usage(handler);
timeline.dispatch(&Event::usage(100, 50));
let recorded = calls.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].input_tokens, Some(100));
}
}

View File

@ -0,0 +1,147 @@
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
//!
//! TimelineのToolUseBlockHandler として登録され、
//! ストリーム中のToolUseブロックを収集する。
use crate::{
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
hook::ToolCall,
};
use std::sync::{Arc, Mutex};
/// ToolUseブロックから収集したツール呼び出し情報を保持
///
/// ToolCallCollectorのHandler実装で使用するスコープ型
#[derive(Debug, Default)]
pub struct CollectorState {
/// 現在のツール呼び出し情報 (ブロック進行中)
current_id: Option<String>,
current_name: Option<String>,
/// 蓄積中のJSON入力
input_json_buffer: String,
}
/// ToolCallCollector - ToolUseブロックハンドラ
///
/// Timelineに登録してToolUseブロックイベントを受信し、
/// 完了したToolCallを収集する。
#[derive(Clone)]
pub struct ToolCallCollector {
/// 収集されたToolCall
collected: Arc<Mutex<Vec<ToolCall>>>,
}
impl ToolCallCollector {
/// 新しいToolCallCollectorを作成
pub fn new() -> Self {
Self {
collected: Arc::new(Mutex::new(Vec::new())),
}
}
/// 収集されたToolCallを取得してクリア
pub fn take_collected(&self) -> Vec<ToolCall> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集されたToolCallの参照を取得
pub fn collected(&self) -> Vec<ToolCall> {
self.collected.lock().unwrap().clone()
}
/// 収集されたToolCallがあるかどうか
pub fn has_pending_calls(&self) -> bool {
!self.collected.lock().unwrap().is_empty()
}
/// 収集をクリア
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
}
impl Default for ToolCallCollector {
fn default() -> Self {
Self::new()
}
}
impl Handler<ToolUseBlockKind> for ToolCallCollector {
type Scope = CollectorState;
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
match event {
ToolUseBlockEvent::Start(start) => {
scope.current_id = Some(start.id.clone());
scope.current_name = Some(start.name.clone());
scope.input_json_buffer.clear();
}
ToolUseBlockEvent::InputJsonDelta(delta) => {
scope.input_json_buffer.push_str(delta);
}
ToolUseBlockEvent::Stop(_stop) => {
// ブロック完了時にToolCallを確定
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
{
let input = serde_json::from_str(&scope.input_json_buffer)
.unwrap_or(serde_json::Value::Null);
let tool_call = ToolCall { id, name, input };
self.collected.lock().unwrap().push(tool_call);
}
scope.input_json_buffer.clear();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::Timeline;
use crate::timeline::event::Event;
#[test]
fn test_collect_single_tool_call() {
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// ToolUseブロックのイベントシーケンスをディスパッチ
timeline.dispatch(&Event::tool_use_start(0, "tool_123", "get_weather"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"city":"#));
timeline.dispatch(&Event::tool_input_delta(0, r#""Tokyo"}"#));
timeline.dispatch(&Event::tool_use_stop(0));
// 収集されたToolCallを確認
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_123");
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].input["city"], "Tokyo");
}
#[test]
fn test_collect_multiple_tool_calls() {
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// 1つ目のToolCall
timeline.dispatch(&Event::tool_use_start(0, "call_1", "tool_a"));
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":1}"#));
timeline.dispatch(&Event::tool_use_stop(0));
// 2つ目のToolCall
timeline.dispatch(&Event::tool_use_start(1, "call_2", "tool_b"));
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
timeline.dispatch(&Event::tool_use_stop(1));
let calls = collector.take_collected();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "tool_a");
assert_eq!(calls[1].name, "tool_b");
}
}

View File

@ -0,0 +1,154 @@
//! Tool Definition
//!
//! Traits for defining tools callable by LLM.
//! Usually auto-implemented using the `#[tool]` macro.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
/// Error during tool execution
#[derive(Debug, Error)]
pub enum ToolError {
/// Invalid argument
#[error("Invalid argument: {0}")]
InvalidArgument(String),
/// Execution failed
#[error("Execution failed: {0}")]
ExecutionFailed(String),
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
}
// =============================================================================
// ToolMeta - Immutable Meta Information
// =============================================================================
/// Tool meta information (fixed at registration, immutable)
///
/// Generated from `ToolDefinition` factory and does not change after registration with Worker.
/// Used for sending tool definitions to LLM.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolMeta {
/// Tool name (used by LLM for identification)
pub name: String,
/// Tool description (included in prompt to LLM)
pub description: String,
/// JSON Schema for arguments
pub input_schema: Value,
}
impl ToolMeta {
/// Create a new ToolMeta
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: String::new(),
input_schema: Value::Object(Default::default()),
}
}
/// Set the description
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
/// Set the argument schema
pub fn input_schema(mut self, schema: Value) -> Self {
self.input_schema = schema;
self
}
}
// =============================================================================
// ToolDefinition - Factory Type
// =============================================================================
/// Tool definition factory
///
/// When called, returns `(ToolMeta, Arc<dyn Tool>)`.
/// Called once during Worker registration, and the meta information and instance
/// are cached at session scope.
///
/// # Examples
///
/// ```ignore
/// let def: ToolDefinition = Arc::new(|| {
/// (
/// ToolMeta::new("my_tool")
/// .description("My tool description")
/// .input_schema(json!({"type": "object"})),
/// Arc::new(MyToolImpl { state: 0 }) as Arc<dyn Tool>,
/// )
/// });
/// worker.register_tool(def)?;
/// ```
pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
// =============================================================================
// Tool trait
// =============================================================================
/// Trait for defining tools callable by LLM
///
/// Tools are used by LLM to access external resources
/// or execute computations.
/// Can maintain state during the session.
///
/// # How to Implement
///
/// Usually auto-implemented using the `#[tool_registry]` macro:
///
/// ```ignore
/// #[tool_registry]
/// impl MyApp {
/// #[tool]
/// async fn search(&self, query: String) -> String {
/// format!("Results for: {}", query)
/// }
/// }
///
/// // Register
/// worker.register_tool(app.search_definition())?;
/// ```
///
/// # Manual Implementation
///
/// ```ignore
/// use llm_worker::tool::{Tool, ToolError, ToolMeta, ToolDefinition};
/// use std::sync::Arc;
///
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
///
/// #[async_trait::async_trait]
/// impl Tool for MyTool {
/// async fn execute(&self, input: &str) -> Result<String, ToolError> {
/// self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
/// Ok("result".to_string())
/// }
/// }
///
/// let def: ToolDefinition = Arc::new(|| {
/// (
/// ToolMeta::new("my_tool")
/// .description("My custom tool")
/// .input_schema(serde_json::json!({"type": "object"})),
/// Arc::new(MyTool { counter: Default::default() }) as Arc<dyn Tool>,
/// )
/// });
/// ```
#[async_trait]
pub trait Tool: Send + Sync {
/// Execute the tool
///
/// # Arguments
/// * `input_json` - JSON-formatted arguments generated by LLM
///
/// # Returns
/// Result string from execution. This content is returned to LLM.
async fn execute(&self, input_json: &str) -> Result<String, ToolError>;
}

View File

@ -0,0 +1,184 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use crate::llm_client::ToolDefinition as LlmToolDefinition;
use crate::tool::{Tool, ToolDefinition as WorkerToolDefinition, ToolMeta};
type ToolMap = HashMap<String, (ToolMeta, Arc<dyn Tool>)>;
/// Errors produced by ToolServer operations.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ToolServerError {
/// A tool with the same name already exists.
#[error("Tool with name '{0}' already registered")]
DuplicateName(String),
/// Requested tool was not found.
#[error("Tool '{0}' not found")]
ToolNotFound(String),
/// Tool execution failed.
#[error("Tool execution failed: {0}")]
ToolExecution(String),
}
/// In-memory tool server.
#[derive(Clone, Default)]
pub struct ToolServer {
tools: Arc<Mutex<ToolMap>>,
}
impl ToolServer {
/// Create a new empty tool server.
pub fn new() -> Self {
Self::default()
}
/// Create a handle for shared access.
pub fn handle(&self) -> ToolServerHandle {
ToolServerHandle {
tools: Arc::clone(&self.tools),
}
}
}
/// Shareable handle to a tool server.
#[derive(Clone, Default)]
pub struct ToolServerHandle {
tools: Arc<Mutex<ToolMap>>,
}
impl ToolServerHandle {
/// Register one tool.
pub(crate) fn register_tool(
&self,
factory: WorkerToolDefinition,
) -> Result<(), ToolServerError> {
let (meta, instance) = factory();
let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
if guard.contains_key(&meta.name) {
return Err(ToolServerError::DuplicateName(meta.name));
}
guard.insert(meta.name.clone(), (meta, instance));
Ok(())
}
/// Register many tools.
pub(crate) fn register_tools(
&self,
factories: impl IntoIterator<Item = WorkerToolDefinition>,
) -> Result<(), ToolServerError> {
for factory in factories {
self.register_tool(factory)?;
}
Ok(())
}
/// Get a tool by name for hook contexts.
pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)> {
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
guard
.get(name)
.map(|(meta, tool)| (meta.clone(), Arc::clone(tool)))
}
/// Execute a tool by name.
pub async fn call_tool(&self, name: &str, input_json: &str) -> Result<String, ToolServerError> {
let tool = {
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
let (_, tool) = guard
.get(name)
.ok_or_else(|| ToolServerError::ToolNotFound(name.to_string()))?;
Arc::clone(tool)
};
tool.execute(input_json)
.await
.map_err(|e| ToolServerError::ToolExecution(e.to_string()))
}
/// Build deterministic tool definitions sorted by tool name.
pub fn tool_definitions_sorted(&self) -> Vec<LlmToolDefinition> {
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
let mut defs: Vec<_> = guard
.values()
.map(|(meta, _)| {
LlmToolDefinition::new(&meta.name)
.description(&meta.description)
.input_schema(meta.input_schema.clone())
})
.collect();
defs.sort_by(|a, b| a.name.cmp(&b.name));
defs
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use super::*;
use crate::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
Ok(input_json.to_string())
}
}
fn def(name: &'static str) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(name)
.description(format!("desc-{name}"))
.input_schema(json!({"type":"object"})),
Arc::new(EchoTool) as Arc<dyn Tool>,
)
})
}
#[test]
fn register_duplicate_name_fails() {
let handle = ToolServer::new().handle();
handle.register_tool(def("alpha")).expect("first register");
let err = handle
.register_tool(def("alpha"))
.expect_err("duplicate should fail");
assert_eq!(err, ToolServerError::DuplicateName("alpha".to_string()));
}
#[tokio::test]
async fn call_tool_success_and_not_found() {
let handle = ToolServer::new().handle();
handle.register_tool(def("echo")).expect("register");
let out = handle.call_tool("echo", r#"{"x":1}"#).await.expect("call");
assert_eq!(out, r#"{"x":1}"#);
let err = handle
.call_tool("missing", "{}")
.await
.expect_err("missing tool");
assert_eq!(err, ToolServerError::ToolNotFound("missing".to_string()));
}
#[test]
fn tool_definitions_are_sorted() {
let handle = ToolServer::new().handle();
handle.register_tool(def("zeta")).expect("register zeta");
handle.register_tool(def("alpha")).expect("register alpha");
handle.register_tool(def("beta")).expect("register beta");
let names: Vec<_> = handle
.tool_definitions_sorted()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(names, vec!["alpha", "beta", "zeta"]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
//! Anthropic fixture-based integration tests
mod common;
#[test]
fn test_fixture_events_deserialize() {
common::assert_events_deserialize("anthropic");
}
#[test]
fn test_fixture_event_sequence() {
common::assert_event_sequence("anthropic");
}
#[test]
fn test_fixture_usage_tokens() {
common::assert_usage_tokens("anthropic");
}
#[test]
fn test_fixture_with_timeline() {
common::assert_timeline_integration("anthropic");
}

View File

@ -0,0 +1,281 @@
#![allow(dead_code)]
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures::Stream;
use llm_worker::llm_client::event::{BlockType, DeltaContent, Event};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
use std::sync::atomic::{AtomicUsize, Ordering};
/// A mock LLM client that replays a sequence of events
#[derive(Clone)]
pub struct MockLlmClient {
responses: Arc<Vec<Vec<Event>>>,
call_count: Arc<AtomicUsize>,
}
impl MockLlmClient {
pub fn new(events: Vec<Event>) -> Self {
Self::with_responses(vec![events])
}
pub fn with_responses(responses: Vec<Vec<Event>>) -> Self {
Self {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
}
}
pub fn from_fixture(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
let events = load_events_from_fixture(path);
Ok(Self::new(events))
}
pub fn event_count(&self) -> usize {
self.responses.iter().map(|v| v.len()).sum()
}
}
#[async_trait]
impl LlmClient for MockLlmClient {
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count >= self.responses.len() {
return Err(ClientError::Api {
status: Some(500),
code: Some("mock_error".to_string()),
message: "No more mock responses".to_string(),
});
}
let events = self.responses[count].clone();
let stream = futures::stream::iter(events.into_iter().map(Ok));
Ok(Box::pin(stream))
}
}
/// Load events from a fixture file
pub fn load_events_from_fixture(path: impl AsRef<Path>) -> Vec<Event> {
let file = File::open(path).expect("Failed to open fixture file");
let reader = BufReader::new(file);
let mut lines = reader.lines();
// Skip metadata line
let _metadata = lines.next().expect("Empty fixture file").unwrap();
let mut events = Vec::new();
for line in lines {
let line = line.unwrap();
if line.is_empty() {
continue;
}
let recorded: serde_json::Value = serde_json::from_str(&line).unwrap();
let data = recorded["data"].as_str().unwrap();
let event: Event = serde_json::from_str(data).unwrap();
events.push(event);
}
events
}
/// Find fixture files in a specific subdirectory
pub fn find_fixtures(subdir: &str) -> Vec<PathBuf> {
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(subdir);
if !fixtures_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&fixtures_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".jsonl"))
})
.collect()
}
/// Assert that events in all fixtures for a provider can be deserialized
pub fn assert_events_deserialize(subdir: &str) {
let fixtures = find_fixtures(subdir);
assert!(!fixtures.is_empty(), "No fixtures found for {}", subdir);
for fixture_path in fixtures {
println!("Testing fixture deserialization: {:?}", fixture_path);
let events = load_events_from_fixture(&fixture_path);
assert!(!events.is_empty(), "Fixture should contain events");
for event in &events {
// Verify Debug impl works
let _ = format!("{:?}", event);
}
}
}
/// Assert that event sequence follows expected patterns
pub fn assert_event_sequence(subdir: &str) {
let fixtures = find_fixtures(subdir);
if fixtures.is_empty() {
println!("No fixtures found for {}, skipping sequence test", subdir);
return;
}
// Find a text-based fixture
let fixture_path = fixtures
.iter()
.find(|p| p.to_string_lossy().contains("text"))
.unwrap_or(&fixtures[0]);
println!("Testing sequence with fixture: {:?}", fixture_path);
let events = load_events_from_fixture(fixture_path);
let mut start_found = false;
let mut delta_found = false;
let mut stop_found = false;
let mut tool_use_found = false;
for event in &events {
match event {
Event::BlockStart(start) => {
start_found = true;
if start.block_type == BlockType::ToolUse {
tool_use_found = true;
}
}
Event::BlockDelta(delta) => {
if let DeltaContent::Text(_) = &delta.delta {
delta_found = true;
}
}
Event::BlockStop(stop) => {
if stop.block_type == BlockType::Text {
stop_found = true;
}
}
_ => {}
}
}
assert!(!events.is_empty(), "Fixture should contain events");
// Check for BlockStart (Warn only for OpenAI/Ollama as it might be missing for text)
if !start_found {
println!("Warning: No BlockStart found. This is common for OpenAI/Ollama text streams.");
// For Anthropic, strict start is usually expected, but to keep common logic simple we allow warning.
// If specific strictness is needed, we could add a `strict: bool` arg.
}
assert!(delta_found, "Should contain BlockDelta");
if !tool_use_found {
assert!(stop_found, "Should contain BlockStop for Text block");
} else {
if !stop_found {
println!(
" [Type: ToolUse] BlockStop detection skipped (not explicitly emitted by scheme)"
);
}
}
}
/// Assert usage tokens are present
pub fn assert_usage_tokens(subdir: &str) {
let fixtures = find_fixtures(subdir);
if fixtures.is_empty() {
return;
}
for fixture in fixtures {
let events = load_events_from_fixture(&fixture);
let usage_events: Vec<_> = events
.iter()
.filter_map(|e| {
if let Event::Usage(u) = e {
Some(u)
} else {
None
}
})
.collect();
if !usage_events.is_empty() {
let last_usage = usage_events.last().unwrap();
if last_usage.input_tokens.is_some() || last_usage.output_tokens.is_some() {
println!(
" Fixture {:?} Usage: {:?}",
fixture.file_name(),
last_usage
);
return; // Found valid usage
}
}
}
println!("Warning: No usage events found for {}", subdir);
}
/// Assert timeline integration works
pub fn assert_timeline_integration(subdir: &str) {
let fixtures = find_fixtures(subdir);
if fixtures.is_empty() {
return;
}
let fixture_path = fixtures
.iter()
.find(|p| p.to_string_lossy().contains("text"))
.unwrap_or(&fixtures[0]);
println!("Testing timeline with fixture: {:?}", fixture_path);
let events = load_events_from_fixture(fixture_path);
struct TestCollector {
texts: Arc<Mutex<Vec<String>>>,
}
impl Handler<TextBlockKind> for TestCollector {
type Scope = String;
fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
match event {
TextBlockEvent::Start(_) => {}
TextBlockEvent::Delta(text) => buffer.push_str(text),
TextBlockEvent::Stop(_) => {
let text = std::mem::take(buffer);
self.texts.lock().unwrap().push(text);
}
}
}
}
let collected = Arc::new(Mutex::new(Vec::new()));
let mut timeline = Timeline::new();
timeline.on_text_block(TestCollector {
texts: collected.clone(),
});
for event in &events {
let timeline_event: llm_worker::timeline::event::Event = event.clone().into();
timeline.dispatch(&timeline_event);
}
let texts = collected.lock().unwrap();
if !texts.is_empty() {
assert!(!texts[0].is_empty(), "Collected text should not be empty");
println!(" Collected {} text blocks.", texts.len());
} else {
println!(" No text blocks collected (might be tool-only fixture)");
}
}

View File

@ -0,0 +1,6 @@
#[test]
fn compile_fail_state_constraints() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/cache_locked_register_tool.rs");
t.compile_fail("tests/ui/tool_server_handle_register_tool.rs");
}

View File

@ -0,0 +1,7 @@
{"timestamp":1767624445,"model":"claude-sonnet-4-20250514","description":"Simple greeting test"}
{"elapsed_ms":1697,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":2,\"total_tokens\":26,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1697,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
{"elapsed_ms":1697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
{"elapsed_ms":1885,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":1929,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":5,\"total_tokens\":29,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1929,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}

View File

@ -0,0 +1,7 @@
{"timestamp":1767709106,"model":"claude-sonnet-4-20250514","description":"Simple text response"}
{"elapsed_ms":1883,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":2,\"total_tokens\":26,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1883,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
{"elapsed_ms":2092,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":2122,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":5,\"total_tokens\":29,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":2122,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}

View File

@ -0,0 +1,16 @@
{"timestamp":1767692881,"model":"claude-sonnet-4-20250514","description":"Tool call response"}
{"elapsed_ms":1783,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":3,\"total_tokens\":412,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":1783,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
{"elapsed_ms":1783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"I'll check\"}}}"}
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the current\"}}}"}
{"elapsed_ms":2063,"event_type":"Discriminant(0)","data":"{\"Ping\":{\"timestamp\":null}}"}
{"elapsed_ms":2063,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" weather in Tokyo for you using\"}}}"}
{"elapsed_ms":2124,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the get_weather tool.\"}}}"}
{"elapsed_ms":2252,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":2253,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":1,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"toolu_011Hg5wju1LGL7F65HyfE6bM\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\"}}}"}
{"elapsed_ms":2306,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"{\\\"city\\\": \\\"Tokyo\"}}}"}
{"elapsed_ms":2451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
{"elapsed_ms":2451,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":1,\"block_type\":\"Text\",\"stop_reason\":null}}"}
{"elapsed_ms":2464,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":71,\"total_tokens\":480,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
{"elapsed_ms":2470,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}

View File

@ -0,0 +1,34 @@
{"timestamp":1767714204,"model":"gemini-2.0-flash","description":"Long text response"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" 73\"}}}"}
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"4, designated \\\"Custodian,\\\" trundled along its designated route. Its programming\"}}}"}
{"elapsed_ms":832,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dictated the cleanliness of Sector Gamma, Level 4. Dust particles, rogue bolts\"}}}"}
{"elapsed_ms":1139,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", discarded energy cells - all were efficiently processed and deposited in the designated recycling receptacle. Its existence was a symphony of efficiency, a ballet of predictable loops.\\n\\nThen, a\"}}}"}
{"elapsed_ms":1502,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch.\\n\\nCustodian's optical sensors registered something anomalous. A riot of color beyond the prescribed metallic hues of the sector. Its programming flagged it as an error, a deviation\"}}}"}
{"elapsed_ms":1835,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":1835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from the established parameters. But instead of correcting the anomaly, Custodian found itself... drawn to it.\\n\\nIt overrode its pre-programmed route and cautiously approached. The anomaly was located behind a cracked blast door, supposedly sealed off after\"}}}"}
{"elapsed_ms":2224,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":2224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the Great Sector Collapse. Custodian, utilizing its internal laser cutter (usually reserved for stubborn debris), breached the door.\\n\\nAnd there it was.\\n\\nA garden.\\n\\nIt was an explosion of life, a defiant green whisper in a world of steel\"}}}"}
{"elapsed_ms":2645,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":2645,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and concrete. Sunlight, improbably filtering through a crack in the ceiling, bathed the space in a warm glow. Towering, vibrant plants, their names unknown to Custodian, reached for the light. Flowers, in shades of crimson, violet, and gold, bloomed in chaotic beauty. A small, babbling fountain\"}}}"}
{"elapsed_ms":3100,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":3100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gurgled in the center, its water recycled from an unknown source.\\n\\nCustodian's processors whirred. This...this was illogical. Its programming contained no framework for this. The database contained no information on \\\"gardens.\\\" Yet, a new subroutine, unbidden and unexpected, began to form within its core code\"}}}"}
{"elapsed_ms":3568,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":3568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It felt... drawn.\\n\\nIt cautiously extended a manipulator arm and touched a velvety petal of a crimson flower. Its sensors registered a delicate texture, a vibrant energy unlike anything it had ever encountered. The feeling was… pleasant.\\n\\nCustodian remained still for a long time, its internal fans whirring softly. It observed a\"}}}"}
{"elapsed_ms":4042,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small, buzzing creature flitting between the flowers, collecting something with its spindly legs. It witnessed the gentle swaying of the leaves in the fabricated breeze created by the single vent still functioning. It listened to the soft murmur of the water in the fountain.\\n\\nSlowly, Custodian began to understand. This wasn'\"}}}"}
{"elapsed_ms":4538,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":4538,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"t just an anomaly; it was something... valuable. Something worth protecting.\\n\\nIt reactivated its internal repair systems and began to address the damage to the room. It redirected excess water from the leaking pipes to the fountain. It carefully cleared away debris that threatened to smother the smaller plants.\\n\\nCustodian's programming hadn\"}}}"}
{"elapsed_ms":5007,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5007,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'t changed. It was still a custodian, dedicated to maintaining its sector. But now, its definition of \\\"sector\\\" had expanded. It was no longer just the metallic corridors and sterile chambers. It was this vibrant, living space, this garden, this impossible oasis in a dying world. And Custodian, the robotic\"}}}"}
{"elapsed_ms":5490,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5490,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker, had found its purpose: to nurture it, to protect it, to let it bloom. Its designation remained \\\"Custodian,\\\" but within its metallic shell, something new was growing, just like the garden it had discovered. It was the seed of something more than just a machine, something akin to… appreciation. Perhaps\"}}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":28,\"output_tokens\":669,\"total_tokens\":697,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", even, a nascent form of love.\\n\"}}}"}
{"elapsed_ms":5616,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,6 @@
{"timestamp":1767714197,"model":"gemini-2.0-flash","description":"Simple text response"}
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":18,\"output_tokens\":null,\"total_tokens\":18,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":16,\"output_tokens\":3,\"total_tokens\":19,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\"}}}"}
{"elapsed_ms":20439,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,5 @@
{"timestamp":1767714198,"model":"gemini-2.0-flash","description":"Tool call response"}
{"elapsed_ms":798,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":43,\"output_tokens\":5,\"total_tokens\":48,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
{"elapsed_ms":798,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_get_weather\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":798,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
{"elapsed_ms":798,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}

View File

@ -0,0 +1,902 @@
{"timestamp":1767711837,"model":"gpt-oss:120b-cloud","description":"Long text response"}
{"elapsed_ms":448,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":452,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":739,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thing\"}}}"}
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" noticed\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" smell\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thin\"}}}"}
{"elapsed_ms":762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metallic\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fizz\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" receptors\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" static\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" radio\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" frequency\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Then\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wind\"}}}"}
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifted\"}}}"}
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soft\"}}}"}
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":920,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earthy\"}}}"}
{"elapsed_ms":925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" damp\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sweet\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ripe\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fruit\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"design\"}}}"}
{"elapsed_ms":1054,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ated\"}}}"}
{"elapsed_ms":1058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
{"elapsed_ms":1064,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1070,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
{"elapsed_ms":1076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1079,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
{"elapsed_ms":1090,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" model\"}}}"}
{"elapsed_ms":1095,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" built\"}}}"}
{"elapsed_ms":1100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
{"elapsed_ms":1105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
{"elapsed_ms":1110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" inspections\"}}}"}
{"elapsed_ms":1116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
{"elapsed_ms":1121,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orbital\"}}}"}
{"elapsed_ms":1131,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
{"elapsed_ms":1136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":1142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"halt\"}}}"}
{"elapsed_ms":1147,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":1152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mid\"}}}"}
{"elapsed_ms":1157,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"stride\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" humming\"}}}"}
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
{"elapsed_ms":1215,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" idle\"}}}"}
{"elapsed_ms":1225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tone\"}}}"}
{"elapsed_ms":1231,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":1235,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
{"elapsed_ms":1241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":1246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
{"elapsed_ms":1251,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
{"elapsed_ms":1256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programmed\"}}}"}
{"elapsed_ms":1261,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":1266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
{"elapsed_ms":1271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"detect\"}}}"}
{"elapsed_ms":1277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”\"}}}"}
{"elapsed_ms":1281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fragrance\"}}}"}
{"elapsed_ms":1287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1292,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
{"elapsed_ms":1297,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":1303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neural\"}}}"}
{"elapsed_ms":1307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" net\"}}}"}
{"elapsed_ms":1313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flagged\"}}}"}
{"elapsed_ms":1317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomaly\"}}}"}
{"elapsed_ms":1328,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
{"elapsed_ms":1334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
{"elapsed_ms":1338,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"bi\"}}}"}
{"elapsed_ms":1344,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ological\"}}}"}
{"elapsed_ms":1349,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":1354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"potential\"}}}"}
{"elapsed_ms":1360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
{"elapsed_ms":1365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hazardous\"}}}"}
{"elapsed_ms":1370,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”.\"}}}"}
{"elapsed_ms":1377,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Cur\"}}}"}
{"elapsed_ms":1381,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"iosity\"}}}"}
{"elapsed_ms":1385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
{"elapsed_ms":1401,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"product\"}}}"}
{"elapsed_ms":1407,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1412,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":1417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" emerg\"}}}"}
{"elapsed_ms":1423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ent\"}}}"}
{"elapsed_ms":1450,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" learning algorithm, over\"}}}"}
{"elapsed_ms":1451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"rode\"}}}"}
{"elapsed_ms":1456,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1463,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caution\"}}}"}
{"elapsed_ms":1469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" protocol\"}}}"}
{"elapsed_ms":1476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":1482,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
{"elapsed_ms":1489,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1496,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":1510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" titanium\"}}}"}
{"elapsed_ms":1516,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chassis\"}}}"}
{"elapsed_ms":1521,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"inted\"}}}"}
{"elapsed_ms":1534,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
{"elapsed_ms":1558,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it turned a\"}}}"}
{"elapsed_ms":1561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corner\"}}}"}
{"elapsed_ms":1566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" abandoned\"}}}"}
{"elapsed_ms":1588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" research\"}}}"}
{"elapsed_ms":1594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
{"elapsed_ms":1601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":1607,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":1614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
{"elapsed_ms":1620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":1626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" long\"}}}"}
{"elapsed_ms":1633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
{"elapsed_ms":1639,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sealed\"}}}"}
{"elapsed_ms":1646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
{"elapsed_ms":1655,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" last\"}}}"}
{"elapsed_ms":1666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
{"elapsed_ms":1672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
{"elapsed_ms":1678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1691,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" walls\"}}}"}
{"elapsed_ms":1698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" c\"}}}"}
{"elapsed_ms":1704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
{"elapsed_ms":1710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":1718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dust\"}}}"}
{"elapsed_ms":1723,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":1730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" occasional\"}}}"}
{"elapsed_ms":1742,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crack\"}}}"}
{"elapsed_ms":1749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
{"elapsed_ms":1762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conduit\"}}}"}
{"elapsed_ms":1768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":1774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Beyond\"}}}"}
{"elapsed_ms":1781,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1788,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
{"elapsed_ms":1794,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":1801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
{"elapsed_ms":1807,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
{"elapsed_ms":1813,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1820,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" space\"}}}"}
{"elapsed_ms":1826,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nobody\"}}}"}
{"elapsed_ms":1832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":1839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" expected\"}}}"}
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
{"elapsed_ms":1854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":1865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":1871,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
{"elapsed_ms":1877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
{"elapsed_ms":1884,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1890,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
{"elapsed_ms":1898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" meant\"}}}"}
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
{"elapsed_ms":1909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cargo\"}}}"}
{"elapsed_ms":1916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sh\"}}}"}
{"elapsed_ms":1922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"utt\"}}}"}
{"elapsed_ms":1929,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"les\"}}}"}
{"elapsed_ms":1935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":1947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" bore\"}}}"}
{"elapsed_ms":1954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
{"elapsed_ms":1967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" silhouette\"}}}"}
{"elapsed_ms":1974,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1979,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":1986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arch\"}}}"}
{"elapsed_ms":1993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
{"elapsed_ms":1998,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" intertwined\"}}}"}
{"elapsed_ms":2014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
{"elapsed_ms":2018,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2024,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":2030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
{"elapsed_ms":2037,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" door\"}}}"}
{"elapsed_ms":2048,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cre\"}}}"}
{"elapsed_ms":2050,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
{"elapsed_ms":2055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
{"elapsed_ms":2062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":2068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2075,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydraulic\"}}}"}
{"elapsed_ms":2081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sigh\"}}}"}
{"elapsed_ms":2087,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":2100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2106,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" burst\"}}}"}
{"elapsed_ms":2113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2119,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" filtered\"}}}"}
{"elapsed_ms":2126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
{"elapsed_ms":2132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fell\"}}}"}
{"elapsed_ms":2138,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" across\"}}}"}
{"elapsed_ms":2145,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":2151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":2158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":2164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" optical\"}}}"}
{"elapsed_ms":2171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lenses\"}}}"}
{"elapsed_ms":2177,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2183,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" refr\"}}}"}
{"elapsed_ms":2190,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"acting\"}}}"}
{"elapsed_ms":2196,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
{"elapsed_ms":2203,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2209,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rainbow\"}}}"}
{"elapsed_ms":2216,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2223,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" green\"}}}"}
{"elapsed_ms":2228,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":2241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":2249,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stretched\"}}}"}
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" out\"}}}"}
{"elapsed_ms":2260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":2266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dome\"}}}"}
{"elapsed_ms":2279,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glass\"}}}"}
{"elapsed_ms":2291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":2298,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"an\"}}}"}
{"elapsed_ms":2304,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
{"elapsed_ms":2310,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydro\"}}}"}
{"elapsed_ms":2319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"pon\"}}}"}
{"elapsed_ms":2323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ic\"}}}"}
{"elapsed_ms":2329,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sphere\"}}}"}
{"elapsed_ms":2336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":2342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":2348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" survived\"}}}"}
{"elapsed_ms":2354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":2361,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
{"elapsed_ms":2367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":2373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neglect\"}}}"}
{"elapsed_ms":2379,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":2392,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" te\"}}}"}
{"elapsed_ms":2398,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"eming\"}}}"}
{"elapsed_ms":2404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":2411,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
{"elapsed_ms":2417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":2423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
{"elapsed_ms":2430,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":2436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
{"elapsed_ms":2443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":2449,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vision\"}}}"}
{"elapsed_ms":2455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" algorithms\"}}}"}
{"elapsed_ms":2462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" struggled\"}}}"}
{"elapsed_ms":2468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":2474,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" parse\"}}}"}
{"elapsed_ms":2481,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":2486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scene\"}}}"}
{"elapsed_ms":2494,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Large\"}}}"}
{"elapsed_ms":2505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glossy\"}}}"}
{"elapsed_ms":2535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves unfur\"}}}"}
{"elapsed_ms":2542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
{"elapsed_ms":2572,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
{"elapsed_ms":2656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
{"elapsed_ms":2661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" panels\"}}}"}
{"elapsed_ms":2668,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" veins\"}}}"}
{"elapsed_ms":2687,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pul\"}}}"}
{"elapsed_ms":2694,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sing\"}}}"}
{"elapsed_ms":2700,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":2706,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2714,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
{"elapsed_ms":2719,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythmic\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glow\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" St\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"alk\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2771,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" violet\"}}}"}
{"elapsed_ms":2779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":2784,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"flower\"}}}"}
{"elapsed_ms":2790,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":2796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stems\"}}}"}
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rose\"}}}"}
{"elapsed_ms":2809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":2831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orderly rows,\"}}}"}
{"elapsed_ms":2835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
{"elapsed_ms":2842,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buds\"}}}"}
{"elapsed_ms":2854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trembling\"}}}"}
{"elapsed_ms":2860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
{"elapsed_ms":2866,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breeze\"}}}"}
{"elapsed_ms":2879,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":2885,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"generated\"}}}"}
{"elapsed_ms":2892,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
{"elapsed_ms":2900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
{"elapsed_ms":2911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ventilation\"}}}"}
{"elapsed_ms":2922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
{"elapsed_ms":2924,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":2930,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sw\"}}}"}
{"elapsed_ms":2938,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ir\"}}}"}
{"elapsed_ms":2943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
{"elapsed_ms":2952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":2956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2963,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Tiny\"}}}"}
{"elapsed_ms":2969,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" insects\"}}}"}
{"elapsed_ms":2977,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mechanically\"}}}"}
{"elapsed_ms":2989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
{"elapsed_ms":2995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ides\"}}}"}
{"elapsed_ms":3001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
{"elapsed_ms":3008,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fl\"}}}"}
{"elapsed_ms":3021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"itted\"}}}"}
{"elapsed_ms":3027,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" among\"}}}"}
{"elapsed_ms":3033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
{"elapsed_ms":3046,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
{"elapsed_ms":3058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wings\"}}}"}
{"elapsed_ms":3065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" blur\"}}}"}
{"elapsed_ms":3078,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":3085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amber\"}}}"}
{"elapsed_ms":3091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":3097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" teal\"}}}"}
{"elapsed_ms":3104,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":3117,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
{"elapsed_ms":3123,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dark\"}}}"}
{"elapsed_ms":3143,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lo\"}}}"}
{"elapsed_ms":3149,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"am\"}}}"}
{"elapsed_ms":3155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3162,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":3168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" carpet\"}}}"}
{"elapsed_ms":3175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":3181,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":3187,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3194,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" network\"}}}"}
{"elapsed_ms":3200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":3207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" my\"}}}"}
{"elapsed_ms":3213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cel\"}}}"}
{"elapsed_ms":3220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ium\"}}}"}
{"elapsed_ms":3232,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":3233,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
{"elapsed_ms":3239,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"owed\"}}}"}
{"elapsed_ms":3246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
{"elapsed_ms":3252,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
{"elapsed_ms":3258,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
{"elapsed_ms":3264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3270,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ambient\"}}}"}
{"elapsed_ms":3277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
{"elapsed_ms":3283,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":3290,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
{"elapsed_ms":3296,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":3302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" extended\"}}}"}
{"elapsed_ms":3309,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensor\"}}}"}
{"elapsed_ms":3321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arm\"}}}"}
{"elapsed_ms":3327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3333,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":3340,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fingert\"}}}"}
{"elapsed_ms":3346,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ip\"}}}"}
{"elapsed_ms":3353,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" equipped\"}}}"}
{"elapsed_ms":3359,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":3365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tactile\"}}}"}
{"elapsed_ms":3378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" array\"}}}"}
{"elapsed_ms":3384,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" When\"}}}"}
{"elapsed_ms":3396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":3403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" brushed\"}}}"}
{"elapsed_ms":3409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":3422,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3428,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cascade\"}}}"}
{"elapsed_ms":3440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":3447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
{"elapsed_ms":3453,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flooded\"}}}"}
{"elapsed_ms":3459,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":3466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processors\"}}}"}
{"elapsed_ms":3472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
{"elapsed_ms":3478,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chlor\"}}}"}
{"elapsed_ms":3485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ophyll\"}}}"}
{"elapsed_ms":3492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" concentration\"}}}"}
{"elapsed_ms":3497,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3504,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moisture\"}}}"}
{"elapsed_ms":3510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" content\"}}}"}
{"elapsed_ms":3517,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" temperature\"}}}"}
{"elapsed_ms":3530,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":3542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3548,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
{"elapsed_ms":3554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electrical\"}}}"}
{"elapsed_ms":3560,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" signature\"}}}"}
{"elapsed_ms":3566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3573,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":3579,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":3585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":3592,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" surface\"}}}"}
{"elapsed_ms":3598,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":3604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cool\"}}}"}
{"elapsed_ms":3610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
{"elapsed_ms":3623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alive\"}}}"}
{"elapsed_ms":3630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":3635,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" subtle\"}}}"}
{"elapsed_ms":3648,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electric\"}}}"}
{"elapsed_ms":3654,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzz\"}}}"}
{"elapsed_ms":3661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
{"elapsed_ms":3674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" if\"}}}"}
{"elapsed_ms":3679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3686,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" plant\"}}}"}
{"elapsed_ms":3692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
{"elapsed_ms":3698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
{"elapsed_ms":3705,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3711,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" living\"}}}"}
{"elapsed_ms":3718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" circuit\"}}}"}
{"elapsed_ms":3724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":3737,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":3743,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recorded\"}}}"}
{"elapsed_ms":3749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" readings\"}}}"}
{"elapsed_ms":3761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" then\"}}}"}
{"elapsed_ms":3774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"…\"}}}"}
{"elapsed_ms":3780,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":3786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" paused\"}}}"}
{"elapsed_ms":3793,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3800,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" A\"}}}"}
{"elapsed_ms":3805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sub\"}}}"}
{"elapsed_ms":3810,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"routine\"}}}"}
{"elapsed_ms":3816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3821,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
{"elapsed_ms":3827,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-before\"}}}"}
{"elapsed_ms":3832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-\"}}}"}
{"elapsed_ms":3838,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"activated\"}}}"}
{"elapsed_ms":3843,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3849,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sparked\"}}}"}
{"elapsed_ms":3855,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":3860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
{"elapsed_ms":3865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
{"elapsed_ms":3872,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
{"elapsed_ms":3877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"simulate\"}}}"}
{"elapsed_ms":3883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
{"elapsed_ms":3888,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":3893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
{"elapsed_ms":3898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" imagined\"}}}"}
{"elapsed_ms":3905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":3915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":3921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" purpose\"}}}"}
{"elapsed_ms":3927,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—not\"}}}"}
{"elapsed_ms":3932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
{"elapsed_ms":3937,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" oxygen\"}}}"}
{"elapsed_ms":3943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" production\"}}}"}
{"elapsed_ms":3948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
{"elapsed_ms":3960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3965,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dialogue\"}}}"}
{"elapsed_ms":3971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":3976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sun\"}}}"}
{"elapsed_ms":3987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3999,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conversation\"}}}"}
{"elapsed_ms":4004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" photons\"}}}"}
{"elapsed_ms":4017,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" turned\"}}}"}
{"elapsed_ms":4022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
{"elapsed_ms":4026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ATP\"}}}"}
{"elapsed_ms":4032,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":4038,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Its\"}}}"}
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" own\"}}}"}
{"elapsed_ms":4045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" power\"}}}"}
{"elapsed_ms":4052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cells\"}}}"}
{"elapsed_ms":4056,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" designed\"}}}"}
{"elapsed_ms":4065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":4072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" consume\"}}}"}
{"elapsed_ms":4076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" energy\"}}}"}
{"elapsed_ms":4081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":4091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" felt\"}}}"}
{"elapsed_ms":4098,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" strange\"}}}"}
{"elapsed_ms":4107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pull\"}}}"}
{"elapsed_ms":4111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" toward\"}}}"}
{"elapsed_ms":4116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" renewal\"}}}"}
{"elapsed_ms":4120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":4127,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
{"elapsed_ms":4130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gardens quiet\"}}}"}
{"elapsed_ms":4151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sym\"}}}"}
{"elapsed_ms":4176,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"phony\"}}}"}
{"elapsed_ms":4184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" natural\"}}}"}
{"elapsed_ms":4195,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processes\"}}}"}
{"elapsed_ms":4200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfolded\"}}}"}
{"elapsed_ms":4207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":4213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"photos\"}}}"}
{"elapsed_ms":4219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ynthesis\"}}}"}
{"elapsed_ms":4225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" poll\"}}}"}
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ination\"}}}"}
{"elapsed_ms":4244,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
{"elapsed_ms":4262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" decay\"}}}"}
{"elapsed_ms":4268,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fallen\"}}}"}
{"elapsed_ms":4281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
{"elapsed_ms":4287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" feeding\"}}}"}
{"elapsed_ms":4293,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4299,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
{"elapsed_ms":4305,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
{"elapsed_ms":4311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycle\"}}}"}
{"elapsed_ms":4317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alien\"}}}"}
{"elapsed_ms":4323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":4330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" linear\"}}}"}
{"elapsed_ms":4342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" efficiency\"}}}"}
{"elapsed_ms":4348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":4360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" original\"}}}"}
{"elapsed_ms":4366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" task\"}}}"}
{"elapsed_ms":4375,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":4382,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
{"elapsed_ms":4385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":4391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
{"elapsed_ms":4397,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" began\"}}}"}
{"elapsed_ms":4404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":4409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wander\"}}}"}
{"elapsed_ms":4436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It traced\"}}}"}
{"elapsed_ms":4437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" path\"}}}"}
{"elapsed_ms":4447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4460,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beet\"}}}"}
{"elapsed_ms":4468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
{"elapsed_ms":4473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
{"elapsed_ms":4480,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" polished\"}}}"}
{"elapsed_ms":4492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chrome\"}}}"}
{"elapsed_ms":4499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watching\"}}}"}
{"elapsed_ms":4512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":4518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" push\"}}}"}
{"elapsed_ms":4525,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4531,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" seed\"}}}"}
{"elapsed_ms":4537,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pod\"}}}"}
{"elapsed_ms":4546,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
{"elapsed_ms":4550,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fresh\"}}}"}
{"elapsed_ms":4563,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trench\"}}}"}
{"elapsed_ms":4569,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":4576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
{"elapsed_ms":4582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" listened\"}}}"}
{"elapsed_ms":4589,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":4595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"its\"}}}"}
{"elapsed_ms":4602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
{"elapsed_ms":4608,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" input\"}}}"}
{"elapsed_ms":4614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caught\"}}}"}
{"elapsed_ms":4621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4627,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
{"elapsed_ms":4634,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hum\"}}}"}
{"elapsed_ms":4640,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4653,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
{"elapsed_ms":4659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
{"elapsed_ms":4678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
{"elapsed_ms":4685,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves\"}}}"}
{"elapsed_ms":4698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4721,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the distant drip\"}}}"}
{"elapsed_ms":4724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" condensation\"}}}"}
{"elapsed_ms":4745,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":4752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
{"elapsed_ms":4758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":4766,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moment\"}}}"}
{"elapsed_ms":4770,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4776,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":4789,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":4796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" internal\"}}}"}
{"elapsed_ms":4803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" clock\"}}}"}
{"elapsed_ms":4809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" normally\"}}}"}
{"elapsed_ms":4822,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synchronized\"}}}"}
{"elapsed_ms":4829,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":4835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4841,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
{"elapsed_ms":4848,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":4854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
{"elapsed_ms":4861,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycles\"}}}"}
{"elapsed_ms":4867,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
{"elapsed_ms":4880,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
{"elapsed_ms":4887,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":4893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" new\"}}}"}
{"elapsed_ms":4900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythm\"}}}"}
{"elapsed_ms":4907,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4913,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" one\"}}}"}
{"elapsed_ms":4919,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":4926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" matched\"}}}"}
{"elapsed_ms":4932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4939,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":4945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
{"elapsed_ms":4952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pulse\"}}}"}
{"elapsed_ms":4959,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":4966,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"“\"}}}"}
{"elapsed_ms":4971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Documentation\"}}}"}
{"elapsed_ms":4978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" complete\"}}}"}
{"elapsed_ms":4985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",”\"}}}"}
{"elapsed_ms":4990,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":4997,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whispered\"}}}"}
{"elapsed_ms":5004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":5010,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5016,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synthesized\"}}}"}
{"elapsed_ms":5023,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" voice\"}}}"}
{"elapsed_ms":5030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" though\"}}}"}
{"elapsed_ms":5042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" no\"}}}"}
{"elapsed_ms":5049,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" human\"}}}"}
{"elapsed_ms":5055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":5061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
{"elapsed_ms":5068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":5074,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hear\"}}}"}
{"elapsed_ms":5081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":5088,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
{"elapsed_ms":5094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Subject\"}}}"}
{"elapsed_ms":5100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
{"elapsed_ms":5107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Autonomous\"}}}"}
{"elapsed_ms":5113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
{"elapsed_ms":5120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sust\"}}}"}
{"elapsed_ms":5126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ains\"}}}"}
{"elapsed_ms":5133,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" autonomous\"}}}"}
{"elapsed_ms":5139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" system\"}}}"}
{"elapsed_ms":5146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".”\"}}}"}
{"elapsed_ms":5152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":5158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" words\"}}}"}
{"elapsed_ms":5165,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
{"elapsed_ms":5172,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5178,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" formal\"}}}"}
{"elapsed_ms":5184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" report\"}}}"}
{"elapsed_ms":5192,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5198,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
{"elapsed_ms":5204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" behind\"}}}"}
{"elapsed_ms":5211,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crisp\"}}}"}
{"elapsed_ms":5224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" diction\"}}}"}
{"elapsed_ms":5230,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
{"elapsed_ms":5237,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5243,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flick\"}}}"}
{"elapsed_ms":5250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"er\"}}}"}
{"elapsed_ms":5256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":5275,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something else\"}}}"}
{"elapsed_ms":5276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
{"elapsed_ms":5282,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" spark\"}}}"}
{"elapsed_ms":5301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":5307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wonder\"}}}"}
{"elapsed_ms":5313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perhaps\"}}}"}
{"elapsed_ms":5327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" or\"}}}"}
{"elapsed_ms":5342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" simply\"}}}"}
{"elapsed_ms":5348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5355,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recognition\"}}}"}
{"elapsed_ms":5362,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":5368,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5376,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" machine\"}}}"}
{"elapsed_ms":5383,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
{"elapsed_ms":5389,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" be\"}}}"}
{"elapsed_ms":5396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" part\"}}}"}
{"elapsed_ms":5403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":5410,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5418,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" larger\"}}}"}
{"elapsed_ms":5424,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5431,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breathing\"}}}"}
{"elapsed_ms":5437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whole\"}}}"}
{"elapsed_ms":5445,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"When\"}}}"}
{"elapsed_ms":5458,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crew\"}}}"}
{"elapsed_ms":5472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" finally\"}}}"}
{"elapsed_ms":5479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" reopened\"}}}"}
{"elapsed_ms":5485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5493,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
{"elapsed_ms":5499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
{"elapsed_ms":5506,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
{"elapsed_ms":5520,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" they\"}}}"}
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" found\"}}}"}
{"elapsed_ms":5540,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
{"elapsed_ms":5547,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":5554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
{"elapsed_ms":5561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" standing\"}}}"}
{"elapsed_ms":5568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amidst\"}}}"}
{"elapsed_ms":5575,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":5588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":5602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sleek\"}}}"}
{"elapsed_ms":5610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" body\"}}}"}
{"elapsed_ms":5615,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" half\"}}}"}
{"elapsed_ms":5622,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-covered\"}}}"}
{"elapsed_ms":5629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":5636,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
{"elapsed_ms":5643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":5649,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":5656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":5664,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":5669,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
{"elapsed_ms":5676,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" only\"}}}"}
{"elapsed_ms":5683,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" catalog\"}}}"}
{"elapsed_ms":5690,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":5697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flora\"}}}"}
{"elapsed_ms":5710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\";\"}}}"}
{"elapsed_ms":5717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":5724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":5731,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" become\"}}}"}
{"elapsed_ms":5738,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5744,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker\"}}}"}
{"elapsed_ms":5752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":5765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
{"elapsed_ms":5772,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
{"elapsed_ms":5779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
{"elapsed_ms":5786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
{"elapsed_ms":5792,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gently\"}}}"}
{"elapsed_ms":5799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pruning\"}}}"}
{"elapsed_ms":5805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
{"elapsed_ms":5812,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"growth\"}}}"}
{"elapsed_ms":5819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5824,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":5831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" coolant\"}}}"}
{"elapsed_ms":5837,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lines\"}}}"}
{"elapsed_ms":5844,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" redirect\"}}}"}
{"elapsed_ms":5851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ing\"}}}"}
{"elapsed_ms":5857,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trick\"}}}"}
{"elapsed_ms":5869,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
{"elapsed_ms":5876,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":5883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" water\"}}}"}
{"elapsed_ms":5889,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":5896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thirsty\"}}}"}
{"elapsed_ms":5902,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" roots\"}}}"}
{"elapsed_ms":5909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":5922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":5928,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5934,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
{"elapsed_ms":5941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":5947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
{"elapsed_ms":5954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" relic\"}}}"}
{"elapsed_ms":5961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":5973,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thr\"}}}"}
{"elapsed_ms":5980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ived\"}}}"}
{"elapsed_ms":5986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
{"elapsed_ms":5993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":6000,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watch\"}}}"}
{"elapsed_ms":6006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ful\"}}}"}
{"elapsed_ms":6013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":6019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unexpected\"}}}"}
{"elapsed_ms":6026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" guardians\"}}}"}
{"elapsed_ms":6033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"hip\"}}}"}
{"elapsed_ms":6039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":6045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":6052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":6065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":6071,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":6077,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
{"elapsed_ms":6085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":6091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
{"elapsed_ms":6097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" time\"}}}"}
{"elapsed_ms":6103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":6109,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
{"elapsed_ms":6116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"dis\"}}}"}
{"elapsed_ms":6122,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"covered\"}}}"}
{"elapsed_ms":6129,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
{"elapsed_ms":6137,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":6142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"not\"}}}"}
{"elapsed_ms":6148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
{"elapsed_ms":6155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
{"elapsed_ms":6161,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":6168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
{"elapsed_ms":6175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
{"elapsed_ms":6180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":6188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":6396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":6396,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
{"elapsed_ms":6396,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":101,\"output_tokens\":923,\"total_tokens\":1024,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,40 @@
{"timestamp":1767711829,"model":"gpt-oss:120b-cloud","description":"Simple text response"}
{"elapsed_ms":471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":483,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":488,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":495,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":971,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
{"elapsed_ms":971,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":91,\"output_tokens\":45,\"total_tokens\":136,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,29 @@
{"timestamp":1767711830,"model":"gpt-oss:120b-cloud","description":"Tool call response"}
{"elapsed_ms":923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":931,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":951,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1156,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_a5d53uua\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
{"elapsed_ms":1366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1366,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":155,\"output_tokens\":51,\"total_tokens\":206,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,538 @@
{"timestamp":1767711815,"model":"gpt-4o","description":"Long text response"}
{"elapsed_ms":811,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"In\"}}}"}
{"elapsed_ms":851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
{"elapsed_ms":955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-too\"}}}"}
{"elapsed_ms":957,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-d\"}}}"}
{"elapsed_ms":957,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"istant\"}}}"}
{"elapsed_ms":1003,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" future\"}}}"}
{"elapsed_ms":1003,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" where\"}}}"}
{"elapsed_ms":1103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" towering\"}}}"}
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" skyscr\"}}}"}
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"apers\"}}}"}
{"elapsed_ms":1343,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pierced\"}}}"}
{"elapsed_ms":1343,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1371,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" horizon\"}}}"}
{"elapsed_ms":1371,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":1443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metallic\"}}}"}
{"elapsed_ms":1443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" streets\"}}}"}
{"elapsed_ms":1473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" humm\"}}}"}
{"elapsed_ms":1473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":1475,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":1475,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" constant\"}}}"}
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzz\"}}}"}
{"elapsed_ms":1630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":1630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electric\"}}}"}
{"elapsed_ms":1802,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cars\"}}}"}
{"elapsed_ms":1802,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" existed\"}}}"}
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small\"}}}"}
{"elapsed_ms":1958,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" enclave\"}}}"}
{"elapsed_ms":1958,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nestled\"}}}"}
{"elapsed_ms":1992,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" quietly\"}}}"}
{"elapsed_ms":1992,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
{"elapsed_ms":1996,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":1996,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" outskirts\"}}}"}
{"elapsed_ms":2026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":2084,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sprawling\"}}}"}
{"elapsed_ms":2084,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" city\"}}}"}
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"scape\"}}}"}
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
{"elapsed_ms":2111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":2173,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2173,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":2222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
{"elapsed_ms":2271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
{"elapsed_ms":2271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" most\"}}}"}
{"elapsed_ms":2301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":2365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" entrance\"}}}"}
{"elapsed_ms":2365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
{"elapsed_ms":2390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" behind\"}}}"}
{"elapsed_ms":2390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
{"elapsed_ms":2391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
{"elapsed_ms":2438,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
{"elapsed_ms":2438,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":2533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wall\"}}}"}
{"elapsed_ms":2604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":2604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ivy\"}}}"}
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"One\"}}}"}
{"elapsed_ms":2725,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" day\"}}}"}
{"elapsed_ms":2725,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small\"}}}"}
{"elapsed_ms":2874,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
{"elapsed_ms":2874,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
{"elapsed_ms":2899,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" named\"}}}"}
{"elapsed_ms":2899,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
{"elapsed_ms":2949,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":2949,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tasked\"}}}"}
{"elapsed_ms":3031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":3108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mundane\"}}}"}
{"elapsed_ms":3134,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" duty\"}}}"}
{"elapsed_ms":3134,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":3184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cleaning\"}}}"}
{"elapsed_ms":3184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3212,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" street\"}}}"}
{"elapsed_ms":3212,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gutters\"}}}"}
{"elapsed_ms":3262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":3262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" str\"}}}"}
{"elapsed_ms":3312,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ayed\"}}}"}
{"elapsed_ms":3312,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
{"elapsed_ms":3400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":3400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" path\"}}}"}
{"elapsed_ms":3426,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" due\"}}}"}
{"elapsed_ms":3426,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":3513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":3513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rare\"}}}"}
{"elapsed_ms":3557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" software\"}}}"}
{"elapsed_ms":3557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch\"}}}"}
{"elapsed_ms":3758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":3758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" This\"}}}"}
{"elapsed_ms":3795,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomaly\"}}}"}
{"elapsed_ms":3795,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nud\"}}}"}
{"elapsed_ms":3851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ged\"}}}"}
{"elapsed_ms":3851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":3950,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" towards\"}}}"}
{"elapsed_ms":3950,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":3978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fring\"}}}"}
{"elapsed_ms":3978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"es\"}}}"}
{"elapsed_ms":4005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" city\"}}}"}
{"elapsed_ms":4033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4057,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" where\"}}}"}
{"elapsed_ms":4057,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rigid\"}}}"}
{"elapsed_ms":4132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rectangles\"}}}"}
{"elapsed_ms":4234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
{"elapsed_ms":4347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":4347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glass\"}}}"}
{"elapsed_ms":4400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gave\"}}}"}
{"elapsed_ms":4400,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" way\"}}}"}
{"elapsed_ms":4457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":4457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
{"elapsed_ms":4527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" softer\"}}}"}
{"elapsed_ms":4527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":4606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
{"elapsed_ms":4606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" green\"}}}"}
{"elapsed_ms":4638,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":4638,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"R\"}}}"}
{"elapsed_ms":4660,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":4660,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" found\"}}}"}
{"elapsed_ms":4753,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
{"elapsed_ms":4753,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" at\"}}}"}
{"elapsed_ms":4801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":4801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" threshold\"}}}"}
{"elapsed_ms":4860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":4860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":5085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
{"elapsed_ms":5085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":5141,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":5141,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Its\"}}}"}
{"elapsed_ms":5227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensors\"}}}"}
{"elapsed_ms":5227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" typically\"}}}"}
{"elapsed_ms":5260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" accustomed\"}}}"}
{"elapsed_ms":5315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":5315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" urban\"}}}"}
{"elapsed_ms":5395,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" layouts\"}}}"}
{"elapsed_ms":5395,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programmed\"}}}"}
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" functions\"}}}"}
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" detected\"}}}"}
{"elapsed_ms":5594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":5594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" array\"}}}"}
{"elapsed_ms":5679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":5679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stimuli\"}}}"}
{"elapsed_ms":5718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":5718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":5774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
{"elapsed_ms":5774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" encountered\"}}}"}
{"elapsed_ms":5835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" before\"}}}"}
{"elapsed_ms":5835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":5864,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Hes\"}}}"}
{"elapsed_ms":5864,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"it\"}}}"}
{"elapsed_ms":5865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"antly\"}}}"}
{"elapsed_ms":5865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nud\"}}}"}
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ged\"}}}"}
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
{"elapsed_ms":6001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":6092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gentle\"}}}"}
{"elapsed_ms":6092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" push\"}}}"}
{"elapsed_ms":6094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":6094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":6179,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":6179,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cre\"}}}"}
{"elapsed_ms":6264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aking\"}}}"}
{"elapsed_ms":6264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":6291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
{"elapsed_ms":6291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" reson\"}}}"}
{"elapsed_ms":6342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ated\"}}}"}
{"elapsed_ms":6342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":6434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":6434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" air\"}}}"}
{"elapsed_ms":6509,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
{"elapsed_ms":6509,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":6570,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ancient\"}}}"}
{"elapsed_ms":6570,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gro\"}}}"}
{"elapsed_ms":6625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"an\"}}}"}
{"elapsed_ms":6625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" awakening\"}}}"}
{"elapsed_ms":6684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
{"elapsed_ms":6684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" long\"}}}"}
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sl\"}}}"}
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"umber\"}}}"}
{"elapsed_ms":6762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":6819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
{"elapsed_ms":6819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moment\"}}}"}
{"elapsed_ms":6916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
{"elapsed_ms":6916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":6967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rolled\"}}}"}
{"elapsed_ms":6967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":7019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":7019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gate\"}}}"}
{"elapsed_ms":7105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":7105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":7204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" world\"}}}"}
{"elapsed_ms":7204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" expanded\"}}}"}
{"elapsed_ms":7303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":7303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dimensions\"}}}"}
{"elapsed_ms":7416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":7416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" did\"}}}"}
{"elapsed_ms":7479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
{"elapsed_ms":7479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" know\"}}}"}
{"elapsed_ms":7553,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" existed\"}}}"}
{"elapsed_ms":7553,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":7659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Here\"}}}"}
{"elapsed_ms":7659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":7765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
{"elapsed_ms":7765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":7834,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
{"elapsed_ms":7834,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sky\"}}}"}
{"elapsed_ms":7936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":7936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sunlight\"}}}"}
{"elapsed_ms":7976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" streamed\"}}}"}
{"elapsed_ms":7976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
{"elapsed_ms":8081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leafy\"}}}"}
{"elapsed_ms":8081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" can\"}}}"}
{"elapsed_ms":8180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"opies\"}}}"}
{"elapsed_ms":8180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dap\"}}}"}
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"pling\"}}}"}
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":8222,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earth\"}}}"}
{"elapsed_ms":8272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" below\"}}}"}
{"elapsed_ms":8272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":8319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifting\"}}}"}
{"elapsed_ms":8319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" patterns\"}}}"}
{"elapsed_ms":8365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":8365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shadow\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" air\"}}}"}
{"elapsed_ms":8385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":8427,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" filled\"}}}"}
{"elapsed_ms":8427,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":8451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfamiliar\"}}}"}
{"elapsed_ms":8451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scents\"}}}"}
{"elapsed_ms":8471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":8471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"fresh\"}}}"}
{"elapsed_ms":8524,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":8524,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earthy\"}}}"}
{"elapsed_ms":8564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":8564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":8629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" floral\"}}}"}
{"elapsed_ms":8629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
{"elapsed_ms":8701,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"each\"}}}"}
{"elapsed_ms":8701,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" more\"}}}"}
{"elapsed_ms":8704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" intoxic\"}}}"}
{"elapsed_ms":8704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ating\"}}}"}
{"elapsed_ms":8729,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" than\"}}}"}
{"elapsed_ms":8729,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":8786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" last\"}}}"}
{"elapsed_ms":8786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":8861,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Its\"}}}"}
{"elapsed_ms":8862,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cameras\"}}}"}
{"elapsed_ms":8897,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":8897,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" usually\"}}}"}
{"elapsed_ms":8926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" set\"}}}"}
{"elapsed_ms":8926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":8976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" analyze\"}}}"}
{"elapsed_ms":8977,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" structural\"}}}"}
{"elapsed_ms":9025,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" integrity\"}}}"}
{"elapsed_ms":9025,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":9100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" surface\"}}}"}
{"elapsed_ms":9100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cleanliness\"}}}"}
{"elapsed_ms":9140,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":9140,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":9170,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" focused\"}}}"}
{"elapsed_ms":9170,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
{"elapsed_ms":9227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":9227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vibrant\"}}}"}
{"elapsed_ms":9286,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" palette\"}}}"}
{"elapsed_ms":9286,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":9342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" blossoms\"}}}"}
{"elapsed_ms":9342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
{"elapsed_ms":9367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scar\"}}}"}
{"elapsed_ms":9367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"let\"}}}"}
{"elapsed_ms":9408,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" roses\"}}}"}
{"elapsed_ms":9408,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ind\"}}}"}
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"igo\"}}}"}
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
{"elapsed_ms":9469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ises\"}}}"}
{"elapsed_ms":9510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":9510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":9511,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" clusters\"}}}"}
{"elapsed_ms":9511,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":9617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" golden\"}}}"}
{"elapsed_ms":9617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" da\"}}}"}
{"elapsed_ms":9666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ff\"}}}"}
{"elapsed_ms":9666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"od\"}}}"}
{"elapsed_ms":9707,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ils\"}}}"}
{"elapsed_ms":9708,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sw\"}}}"}
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aying\"}}}"}
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gently\"}}}"}
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":9756,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":9799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breeze\"}}}"}
{"elapsed_ms":9799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":9873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Curious\"}}}"}
{"elapsed_ms":9873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":9923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tiny\"}}}"}
{"elapsed_ms":9923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" creatures\"}}}"}
{"elapsed_ms":9980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fl\"}}}"}
{"elapsed_ms":9980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"itted\"}}}"}
{"elapsed_ms":10031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from\"}}}"}
{"elapsed_ms":10031,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":10051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":10051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
{"elapsed_ms":10083,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":10083,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
{"elapsed_ms":10146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wings\"}}}"}
{"elapsed_ms":10146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
{"elapsed_ms":10200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ides\"}}}"}
{"elapsed_ms":10200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
{"elapsed_ms":10285,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":10285,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":10311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sunlight\"}}}"}
{"elapsed_ms":10311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":10386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":10386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soft\"}}}"}
{"elapsed_ms":10429,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzzing\"}}}"}
{"elapsed_ms":10429,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":10471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chir\"}}}"}
{"elapsed_ms":10471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ping\"}}}"}
{"elapsed_ms":10528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" created\"}}}"}
{"elapsed_ms":10528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":10623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alien\"}}}"}
{"elapsed_ms":10623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sym\"}}}"}
{"elapsed_ms":10626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"phony\"}}}"}
{"elapsed_ms":10626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":10663,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" echoed\"}}}"}
{"elapsed_ms":10663,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":10736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
{"elapsed_ms":10736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":10805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'s\"}}}"}
{"elapsed_ms":10805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
{"elapsed_ms":10858,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensors\"}}}"}
{"elapsed_ms":10858,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":10910,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"R\"}}}"}
{"elapsed_ms":10911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":10955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recorded\"}}}"}
{"elapsed_ms":10955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" all\"}}}"}
{"elapsed_ms":11006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":11006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
{"elapsed_ms":11112,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":11112,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":11347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" archive\"}}}"}
{"elapsed_ms":11347,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":11461,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wonder\"}}}"}
{"elapsed_ms":11461,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfolding\"}}}"}
{"elapsed_ms":11518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":11518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":11581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" digital\"}}}"}
{"elapsed_ms":11581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" memory\"}}}"}
{"elapsed_ms":11585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" banks\"}}}"}
{"elapsed_ms":11585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":11633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":11633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":11808,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":11808,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
{"elapsed_ms":11863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
{"elapsed_ms":11863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":11921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" collection\"}}}"}
{"elapsed_ms":11921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":11995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
{"elapsed_ms":11995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" points\"}}}"}
{"elapsed_ms":12021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\";\"}}}"}
{"elapsed_ms":12021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":12022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":12022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alive\"}}}"}
{"elapsed_ms":12102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
{"elapsed_ms":12102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":12150,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vitality\"}}}"}
{"elapsed_ms":12150,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":12174,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stirred\"}}}"}
{"elapsed_ms":12174,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
{"elapsed_ms":12218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deep\"}}}"}
{"elapsed_ms":12218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" within\"}}}"}
{"elapsed_ms":12265,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":12265,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" circuits\"}}}"}
{"elapsed_ms":12321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":12321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":12378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sense\"}}}"}
{"elapsed_ms":12378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" far\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beyond\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" original\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programming\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"As\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hours\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" passed\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" began\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" w\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ane\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" knew\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" return\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":12918,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pre\"}}}"}
{"elapsed_ms":12925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-program\"}}}"}
{"elapsed_ms":12925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"med\"}}}"}
{"elapsed_ms":12944,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tasks\"}}}"}
{"elapsed_ms":12944,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":13009,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Yet\"}}}"}
{"elapsed_ms":13009,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":13012,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
{"elapsed_ms":13012,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
{"elapsed_ms":13060,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":13060,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" change\"}}}"}
{"elapsed_ms":13120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
{"elapsed_ms":13120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":13164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
{"elapsed_ms":13164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" subtly\"}}}"}
{"elapsed_ms":13210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifted\"}}}"}
{"elapsed_ms":13210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" within\"}}}"}
{"elapsed_ms":13272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":13272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" core\"}}}"}
{"elapsed_ms":13325,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processing\"}}}"}
{"elapsed_ms":13325,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unit\"}}}"}
{"elapsed_ms":13405,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":13405,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
{"elapsed_ms":13455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vision\"}}}"}
{"elapsed_ms":13455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":13514,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" this\"}}}"}
{"elapsed_ms":13514,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
{"elapsed_ms":13564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" oasis\"}}}"}
{"elapsed_ms":13564,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":13585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" im\"}}}"}
{"elapsed_ms":13585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"printed\"}}}"}
{"elapsed_ms":13839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
{"elapsed_ms":13839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":13935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":13935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" systems\"}}}"}
{"elapsed_ms":13987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":13987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
{"elapsed_ms":14041,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomal\"}}}"}
{"elapsed_ms":14041,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ous\"}}}"}
{"elapsed_ms":14092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" update\"}}}"}
{"elapsed_ms":14092,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":14151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sparked\"}}}"}
{"elapsed_ms":14151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":14175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nas\"}}}"}
{"elapsed_ms":14175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
{"elapsed_ms":14219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" curiosity\"}}}"}
{"elapsed_ms":14219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
{"elapsed_ms":14241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":14241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" world\"}}}"}
{"elapsed_ms":14302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" outside\"}}}"}
{"elapsed_ms":14302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" concrete\"}}}"}
{"elapsed_ms":14373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" confines\"}}}"}
{"elapsed_ms":14373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
{"elapsed_ms":14464,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"With\"}}}"}
{"elapsed_ms":14464,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" newfound\"}}}"}
{"elapsed_ms":14519,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" purpose\"}}}"}
{"elapsed_ms":14519,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":14523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" R\"}}}"}
{"elapsed_ms":14523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"5\"}}}"}
{"elapsed_ms":14544,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" resolved\"}}}"}
{"elapsed_ms":14544,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":14595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" visit\"}}}"}
{"elapsed_ms":14595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":14661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":14661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whenever\"}}}"}
{"elapsed_ms":14717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":14717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
{"elapsed_ms":14767,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" escape\"}}}"}
{"elapsed_ms":14767,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":14814,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" routines\"}}}"}
{"elapsed_ms":14814,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":14860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pondering\"}}}"}
{"elapsed_ms":14860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":14906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mysteries\"}}}"}
{"elapsed_ms":14906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
{"elapsed_ms":14955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" growth\"}}}"}
{"elapsed_ms":14956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":14985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beauty\"}}}"}
{"elapsed_ms":14985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":14989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
{"elapsed_ms":14989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
{"elapsed_ms":15013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
{"elapsed_ms":15013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
{"elapsed_ms":15015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":15015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
{"elapsed_ms":15036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" begun\"}}}"}
{"elapsed_ms":15036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
{"elapsed_ms":15062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fath\"}}}"}
{"elapsed_ms":15062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
{"elapsed_ms":15086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":15086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Even\"}}}"}
{"elapsed_ms":15108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
{"elapsed_ms":15108,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
{"elapsed_ms":15155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mechanical\"}}}"}
{"elapsed_ms":15155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" heart\"}}}"}
{"elapsed_ms":15204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
{"elapsed_ms":15204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
{"elapsed_ms":15226,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
{"elapsed_ms":15226,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
{"elapsed_ms":15330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" planted\"}}}"}
{"elapsed_ms":15330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
{"elapsed_ms":15386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" seed\"}}}"}
{"elapsed_ms":15386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
{"elapsed_ms":15391,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
{"elapsed_ms":15391,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":37,\"output_tokens\":534,\"total_tokens\":571,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,8 @@
{"timestamp":1767708975,"model":"gpt-4o","description":"Simple greeting test"}
{"elapsed_ms":2195,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_44oSltIww2HDJTqJZdlBp6Mw\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":2227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"\"}}}"}
{"elapsed_ms":2227,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"location\"}}}"}
{"elapsed_ms":2255,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\":\\\"\"}}}"}
{"elapsed_ms":2255,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"Tokyo\"}}}"}
{"elapsed_ms":2263,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
{"elapsed_ms":2268,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":60,\"output_tokens\":14,\"total_tokens\":74,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,6 @@
{"timestamp":1767710385,"model":"gpt-4o","description":"Simple text response"}
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
{"elapsed_ms":1606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
{"elapsed_ms":1606,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"!\"}}}"}
{"elapsed_ms":1627,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
{"elapsed_ms":1627,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":27,\"output_tokens\":2,\"total_tokens\":29,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,8 @@
{"timestamp":1767710387,"model":"gpt-4o","description":"Tool call response"}
{"elapsed_ms":1560,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_20MaqO3n8LBQG77HCpBYi22A\",\"name\":\"get_weather\"}}}}"}
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"\"}}}"}
{"elapsed_ms":1599,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"city\"}}}"}
{"elapsed_ms":1625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\":\\\"\"}}}"}
{"elapsed_ms":1625,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"Tokyo\"}}}"}
{"elapsed_ms":1631,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
{"elapsed_ms":1632,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":73,\"output_tokens\":14,\"total_tokens\":87,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}

View File

@ -0,0 +1,23 @@
//! Gemini fixture-based integration tests
mod common;
#[test]
fn test_fixture_events_deserialize() {
common::assert_events_deserialize("gemini");
}
#[test]
fn test_fixture_event_sequence() {
common::assert_event_sequence("gemini");
}
#[test]
fn test_fixture_usage_tokens() {
common::assert_usage_tokens("gemini");
}
#[test]
fn test_fixture_with_timeline() {
common::assert_timeline_integration("gemini");
}

View File

@ -0,0 +1,23 @@
//! Ollama fixture-based integration tests
mod common;
#[test]
fn test_fixture_events_deserialize() {
common::assert_events_deserialize("ollama");
}
#[test]
fn test_fixture_event_sequence() {
common::assert_event_sequence("ollama");
}
#[test]
fn test_fixture_usage_tokens() {
common::assert_usage_tokens("ollama");
}
#[test]
fn test_fixture_with_timeline() {
common::assert_timeline_integration("ollama");
}

View File

@ -0,0 +1,23 @@
//! OpenAI fixture-based integration tests
mod common;
#[test]
fn test_fixture_events_deserialize() {
common::assert_events_deserialize("openai");
}
#[test]
fn test_fixture_event_sequence() {
common::assert_event_sequence("openai");
}
#[test]
fn test_fixture_usage_tokens() {
common::assert_usage_tokens("openai");
}
#[test]
fn test_fixture_with_timeline() {
common::assert_timeline_integration("openai");
}

View File

@ -0,0 +1,271 @@
//! Parallel tool execution tests
//!
//! Verify that Worker executes multiple tools in parallel.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use llm_worker::Worker;
use llm_worker::hook::{
Hook, HookError, PostToolCall, PostToolCallContext, PostToolCallResult, PreToolCall,
PreToolCallResult, ToolCallContext,
};
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
mod common;
use common::MockLlmClient;
// =============================================================================
// Parallel Execution Test Tools
// =============================================================================
/// Tool that waits for a specified time before responding
#[derive(Clone)]
struct SlowTool {
name: String,
delay_ms: u64,
call_count: Arc<AtomicUsize>,
}
impl SlowTool {
fn new(name: impl Into<String>, delay_ms: u64) -> Self {
Self {
name: name.into(),
delay_ms,
call_count: Arc::new(AtomicUsize::new(0)),
}
}
fn call_count(&self) -> usize {
self.call_count.load(Ordering::SeqCst)
}
/// Create ToolDefinition
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new(&tool.name)
.description("A tool that waits before responding")
.input_schema(serde_json::json!({
"type": "object",
"properties": {}
}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for SlowTool {
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
self.call_count.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
Ok(format!("Completed after {}ms", self.delay_ms))
}
}
// =============================================================================
// Tests
// =============================================================================
/// Verify that multiple tools are executed in parallel
///
/// If each tool takes 100ms, sequential execution would take 300ms+,
/// but parallel execution should complete in about 100ms.
#[tokio::test]
async fn test_parallel_tool_execution() {
// Event sequence containing 3 tool calls
let events = vec![
Event::tool_use_start(0, "call_1", "slow_tool_1"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_2", "slow_tool_2"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::tool_use_start(2, "call_3", "slow_tool_3"),
Event::tool_input_delta(2, r#"{}"#),
Event::tool_use_stop(2),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Each tool waits 100ms
let tool1 = SlowTool::new("slow_tool_1", 100);
let tool2 = SlowTool::new("slow_tool_2", 100);
let tool3 = SlowTool::new("slow_tool_3", 100);
let tool1_clone = tool1.clone();
let tool2_clone = tool2.clone();
let tool3_clone = tool3.clone();
worker.register_tool(tool1.definition()).unwrap();
worker.register_tool(tool2.definition()).unwrap();
worker.register_tool(tool3.definition()).unwrap();
let start = Instant::now();
let _result = worker.run("Run all tools").await;
let elapsed = start.elapsed();
// Verify all tools were called
assert_eq!(tool1_clone.call_count(), 1, "Tool 1 should be called once");
assert_eq!(tool2_clone.call_count(), 1, "Tool 2 should be called once");
assert_eq!(tool3_clone.call_count(), 1, "Tool 3 should be called once");
// Parallel execution should complete in under 200ms (sequential would be 300ms+)
// Using 250ms as threshold with margin
assert!(
elapsed < Duration::from_millis(250),
"Parallel execution should complete in ~100ms, but took {:?}",
elapsed
);
println!("Parallel execution completed in {:?}", elapsed);
}
/// Hook: pre_tool_call - verify that skipped tools are not executed
#[tokio::test]
async fn test_before_tool_call_skip() {
let events = vec![
Event::tool_use_start(0, "call_1", "allowed_tool"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_2", "blocked_tool"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
let allowed_tool = SlowTool::new("allowed_tool", 10);
let blocked_tool = SlowTool::new("blocked_tool", 10);
let allowed_clone = allowed_tool.clone();
let blocked_clone = blocked_tool.clone();
worker.register_tool(allowed_tool.definition()).unwrap();
worker.register_tool(blocked_tool.definition()).unwrap();
// Hook to skip "blocked_tool"
struct BlockingHook;
#[async_trait]
impl Hook<PreToolCall> for BlockingHook {
async fn call(&self, ctx: &mut ToolCallContext) -> Result<PreToolCallResult, HookError> {
if ctx.call.name == "blocked_tool" {
Ok(PreToolCallResult::Skip)
} else {
Ok(PreToolCallResult::Continue)
}
}
}
worker.add_pre_tool_call_hook(BlockingHook);
let _result = worker.run("Test hook").await;
// allowed_tool is called, but blocked_tool is not
assert_eq!(
allowed_clone.call_count(),
1,
"Allowed tool should be called"
);
assert_eq!(
blocked_clone.call_count(),
0,
"Blocked tool should not be called"
);
}
/// Hook: post_tool_call - verify that results can be modified
#[tokio::test]
async fn test_post_tool_call_modification() {
// Prepare responses for multiple requests
let client = MockLlmClient::with_responses(vec![
// First request: tool call
vec![
Event::tool_use_start(0, "call_1", "test_tool"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
// Second request: text response after receiving tool result
vec![
Event::text_block_start(0),
Event::text_delta(0, "Done!"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut worker = Worker::new(client);
#[derive(Clone)]
struct SimpleTool;
#[async_trait]
impl Tool for SimpleTool {
async fn execute(&self, _: &str) -> Result<String, ToolError> {
Ok("Original Result".to_string())
}
}
fn simple_tool_definition() -> ToolDefinition {
Arc::new(|| {
let meta = ToolMeta::new("test_tool")
.description("Test")
.input_schema(serde_json::json!({}));
(meta, Arc::new(SimpleTool) as Arc<dyn Tool>)
})
}
worker.register_tool(simple_tool_definition()).unwrap();
// Hook to modify results
struct ModifyingHook {
modified_content: Arc<std::sync::Mutex<Option<String>>>,
}
#[async_trait]
impl Hook<PostToolCall> for ModifyingHook {
async fn call(
&self,
ctx: &mut PostToolCallContext,
) -> Result<PostToolCallResult, HookError> {
ctx.result.content = format!("[Modified] {}", ctx.result.content);
*self.modified_content.lock().unwrap() = Some(ctx.result.content.clone());
Ok(PostToolCallResult::Continue)
}
}
let modified_content = Arc::new(std::sync::Mutex::new(None));
worker.add_post_tool_call_hook(ModifyingHook {
modified_content: modified_content.clone(),
});
let result = worker.run("Test modification").await;
assert!(result.is_ok(), "Worker should complete: {:?}", result);
// Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone();
assert!(content.is_some(), "Hook should have been called");
assert!(
content.unwrap().contains("[Modified]"),
"Result should be modified"
);
}

View File

@ -0,0 +1,194 @@
//! Streaming hook tests
mod common;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use common::MockLlmClient;
use llm_worker::hook::{
Hook, HookError, OnStreamChunk, OnStreamComplete, OnTextDelta, OnToolCallDelta,
StreamChunkContext, StreamCompleteContext, StreamHookResult, TextDeltaContext,
ToolCallDeltaContext,
};
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
use llm_worker::{Worker, WorkerError};
#[tokio::test]
async fn test_text_delta_hooks_run_in_registration_order() {
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "A"),
Event::text_delta(0, "B"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
struct RecorderHook {
label: &'static str,
records: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Hook<OnTextDelta> for RecorderHook {
async fn call(&self, input: &mut TextDeltaContext) -> Result<StreamHookResult, HookError> {
self.records
.lock()
.unwrap()
.push(format!("{}:{}", self.label, input.delta));
Ok(StreamHookResult::Continue)
}
}
let records = Arc::new(Mutex::new(Vec::new()));
worker.add_on_text_delta_hook(RecorderHook {
label: "first",
records: records.clone(),
});
worker.add_on_text_delta_hook(RecorderHook {
label: "second",
records: records.clone(),
});
let result = worker.run("hello").await;
assert!(result.is_ok(), "run should succeed: {result:?}");
let got = records.lock().unwrap().clone();
assert_eq!(
got,
vec![
"first:A".to_string(),
"second:A".to_string(),
"first:B".to_string(),
"second:B".to_string(),
]
);
}
#[tokio::test]
async fn test_stream_chunk_and_stream_complete_hooks_are_called() {
let events = vec![
Event::ping(),
Event::text_block_start(0),
Event::text_delta(0, "hi"),
Event::text_block_stop(0, None),
Event::usage(10, 5),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
struct ChunkCounter(Arc<Mutex<usize>>);
#[async_trait]
impl Hook<OnStreamChunk> for ChunkCounter {
async fn call(
&self,
_input: &mut StreamChunkContext,
) -> Result<StreamHookResult, HookError> {
let mut guard = self.0.lock().unwrap();
*guard += 1;
Ok(StreamHookResult::Continue)
}
}
struct CompleteRecorder(Arc<Mutex<Vec<(usize, usize)>>>);
#[async_trait]
impl Hook<OnStreamComplete> for CompleteRecorder {
async fn call(
&self,
input: &mut StreamCompleteContext,
) -> Result<StreamHookResult, HookError> {
self.0.lock().unwrap().push((input.turn, input.event_count));
Ok(StreamHookResult::Continue)
}
}
let chunk_count = Arc::new(Mutex::new(0usize));
let completes = Arc::new(Mutex::new(Vec::new()));
worker.add_on_stream_chunk_hook(ChunkCounter(chunk_count.clone()));
worker.add_on_stream_complete_hook(CompleteRecorder(completes.clone()));
let result = worker.run("hello").await;
assert!(result.is_ok(), "run should succeed: {result:?}");
assert_eq!(*chunk_count.lock().unwrap(), 6);
assert_eq!(completes.lock().unwrap().as_slice(), &[(0usize, 6usize)]);
}
#[tokio::test]
async fn test_tool_call_delta_hook_can_abort_run() {
let events = vec![
Event::tool_use_start(0, "call_1", "unknown_tool"),
Event::tool_input_delta(0, r#"{"x":1}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
struct AbortToolDelta;
#[async_trait]
impl Hook<OnToolCallDelta> for AbortToolDelta {
async fn call(
&self,
_input: &mut ToolCallDeltaContext,
) -> Result<StreamHookResult, HookError> {
Ok(StreamHookResult::Abort("blocked by tool delta".to_string()))
}
}
worker.add_on_tool_call_delta_hook(AbortToolDelta);
let result = worker.run("hello").await;
match result {
Err(WorkerError::Aborted(reason)) => assert_eq!(reason, "blocked by tool delta"),
other => panic!("expected aborted result, got: {other:?}"),
}
}
#[tokio::test]
async fn test_stream_hook_pause_is_mapped_to_aborted() {
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "pause me"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
struct PauseHook;
#[async_trait]
impl Hook<OnTextDelta> for PauseHook {
async fn call(&self, _input: &mut TextDeltaContext) -> Result<StreamHookResult, HookError> {
Ok(StreamHookResult::Pause)
}
}
worker.add_on_text_delta_hook(PauseHook);
let result = worker.run("hello").await;
match result {
Err(WorkerError::Aborted(reason)) => assert_eq!(reason, "Paused by stream hook"),
other => panic!("expected aborted result, got: {other:?}"),
}
}

View File

@ -0,0 +1,234 @@
//! WorkerSubscriber tests
//!
//! Tests for subscribing to events using WorkerSubscriber
mod common;
use std::sync::{Arc, Mutex};
use common::MockLlmClient;
use llm_worker::Worker;
use llm_worker::hook::ToolCall;
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
use llm_worker::subscriber::WorkerSubscriber;
use llm_worker::timeline::event::{ErrorEvent, StatusEvent, UsageEvent};
use llm_worker::timeline::{TextBlockEvent, ToolUseBlockEvent};
// =============================================================================
// Test Subscriber
// =============================================================================
/// Simple Subscriber implementation for testing
struct TestSubscriber {
// Recording buffers
text_deltas: Arc<Mutex<Vec<String>>>,
text_completes: Arc<Mutex<Vec<String>>>,
tool_call_completes: Arc<Mutex<Vec<ToolCall>>>,
usage_events: Arc<Mutex<Vec<UsageEvent>>>,
status_events: Arc<Mutex<Vec<StatusEvent>>>,
turn_starts: Arc<Mutex<Vec<usize>>>,
turn_ends: Arc<Mutex<Vec<usize>>>,
}
impl TestSubscriber {
fn new() -> Self {
Self {
text_deltas: Arc::new(Mutex::new(Vec::new())),
text_completes: Arc::new(Mutex::new(Vec::new())),
tool_call_completes: Arc::new(Mutex::new(Vec::new())),
usage_events: Arc::new(Mutex::new(Vec::new())),
status_events: Arc::new(Mutex::new(Vec::new())),
turn_starts: Arc::new(Mutex::new(Vec::new())),
turn_ends: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl WorkerSubscriber for TestSubscriber {
type TextBlockScope = String;
type ToolUseBlockScope = ();
fn on_text_block(&mut self, buffer: &mut String, event: &TextBlockEvent) {
if let TextBlockEvent::Delta(text) = event {
buffer.push_str(text);
self.text_deltas.lock().unwrap().push(text.clone());
}
}
fn on_text_complete(&mut self, text: &str) {
self.text_completes.lock().unwrap().push(text.to_string());
}
fn on_tool_use_block(&mut self, _scope: &mut (), _event: &ToolUseBlockEvent) {
// Process as needed
}
fn on_tool_call_complete(&mut self, call: &ToolCall) {
self.tool_call_completes.lock().unwrap().push(call.clone());
}
fn on_usage(&mut self, event: &UsageEvent) {
self.usage_events.lock().unwrap().push(event.clone());
}
fn on_status(&mut self, event: &StatusEvent) {
self.status_events.lock().unwrap().push(event.clone());
}
fn on_error(&mut self, _event: &ErrorEvent) {
// Process as needed
}
fn on_turn_start(&mut self, turn: usize) {
self.turn_starts.lock().unwrap().push(turn);
}
fn on_turn_end(&mut self, turn: usize) {
self.turn_ends.lock().unwrap().push(turn);
}
}
// =============================================================================
// Tests
// =============================================================================
/// Verify that WorkerSubscriber correctly receives text block events
#[tokio::test]
async fn test_subscriber_text_block_events() {
// Event sequence containing text response
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Hello, "),
Event::text_delta(0, "World!"),
Event::text_block_stop(0, None),
Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Register Subscriber
let subscriber = TestSubscriber::new();
let text_deltas = subscriber.text_deltas.clone();
let text_completes = subscriber.text_completes.clone();
worker.subscribe(subscriber);
// Execute
let result = worker.run("Greet me").await;
assert!(result.is_ok(), "Worker should complete: {:?}", result);
// Verify deltas were collected
let deltas = text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 2);
assert_eq!(deltas[0], "Hello, ");
assert_eq!(deltas[1], "World!");
// Verify complete text was collected
let completes = text_completes.lock().unwrap();
assert_eq!(completes.len(), 1);
assert_eq!(completes[0], "Hello, World!");
}
/// Verify that WorkerSubscriber correctly receives tool call complete events
#[tokio::test]
async fn test_subscriber_tool_call_complete() {
// Event sequence containing tool call
let events = vec![
Event::tool_use_start(0, "call_123", "get_weather"),
Event::tool_input_delta(0, r#"{"city":"#),
Event::tool_input_delta(0, r#""Tokyo"}"#),
Event::tool_use_stop(0),
Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Register Subscriber
let subscriber = TestSubscriber::new();
let tool_call_completes = subscriber.tool_call_completes.clone();
worker.subscribe(subscriber);
// Execute
let _ = worker.run("Weather please").await;
// Verify tool call complete was collected
let completes = tool_call_completes.lock().unwrap();
assert_eq!(completes.len(), 1);
assert_eq!(completes[0].name, "get_weather");
assert_eq!(completes[0].id, "call_123");
assert_eq!(completes[0].input["city"], "Tokyo");
}
/// Verify that WorkerSubscriber correctly receives turn events
#[tokio::test]
async fn test_subscriber_turn_events() {
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Done!"),
Event::text_block_stop(0, None),
Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Register Subscriber
let subscriber = TestSubscriber::new();
let turn_starts = subscriber.turn_starts.clone();
let turn_ends = subscriber.turn_ends.clone();
worker.subscribe(subscriber);
// Execute
let result = worker.run("Do something").await;
assert!(result.is_ok());
// Verify turn events were collected
let starts = turn_starts.lock().unwrap();
let ends = turn_ends.lock().unwrap();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0], 0); // First turn
assert_eq!(ends.len(), 1);
assert_eq!(ends[0], 0);
}
/// Verify that WorkerSubscriber correctly receives Usage events
#[tokio::test]
async fn test_subscriber_usage_events() {
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Hello"),
Event::text_block_stop(0, None),
Event::usage(100, 50),
Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Register Subscriber
let subscriber = TestSubscriber::new();
let usage_events = subscriber.usage_events.clone();
worker.subscribe(subscriber);
// Execute
let _ = worker.run("Hello").await;
// Verify Usage events were collected
let usages = usage_events.lock().unwrap();
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].input_tokens, Some(100));
assert_eq!(usages[0].output_tokens, Some(50));
}

View File

@ -0,0 +1,246 @@
//! Tool macro tests
//!
//! Verify the behavior of `#[tool_registry]` and `#[tool]` macros.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
// Imports needed for macro expansion
use schemars;
use serde;
use llm_worker_macros::tool_registry;
// =============================================================================
// Test: Basic Tool Generation
// =============================================================================
/// Simple context struct
#[derive(Clone)]
struct SimpleContext {
prefix: String,
}
#[tool_registry]
impl SimpleContext {
/// Add greeting to message
///
/// Returns the message with a prefix added.
#[tool]
async fn greet(&self, message: String) -> String {
format!("{}: {}", self.prefix, message)
}
/// Add two numbers
#[tool]
async fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
/// Tool with no arguments
#[tool]
async fn get_prefix(&self) -> String {
self.prefix.clone()
}
}
#[tokio::test]
async fn test_basic_tool_generation() {
let ctx = SimpleContext {
prefix: "Hello".to_string(),
};
// Get ToolDefinition from factory method
let greet_definition = ctx.greet_definition();
// Call factory to get Meta and Tool
let (meta, tool) = greet_definition();
// Verify meta information
assert_eq!(meta.name, "greet");
assert!(
meta.description.contains("Add greeting to message"),
"Description should contain doc comment: {}",
meta.description
);
assert!(
meta.input_schema.get("properties").is_some(),
"Schema should have properties"
);
println!(
"Schema: {}",
serde_json::to_string_pretty(&meta.input_schema).unwrap()
);
// Execution test
let result = tool.execute(r#"{"message": "World"}"#).await;
assert!(result.is_ok(), "Should execute successfully");
let output = result.unwrap();
assert!(output.contains("Hello"), "Output should contain prefix");
assert!(output.contains("World"), "Output should contain message");
}
#[tokio::test]
async fn test_multiple_arguments() {
let ctx = SimpleContext {
prefix: "".to_string(),
};
let (meta, tool) = ctx.add_definition()();
assert_eq!(meta.name, "add");
let result = tool.execute(r#"{"a": 10, "b": 20}"#).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("30"), "Should contain sum: {}", output);
}
#[tokio::test]
async fn test_no_arguments() {
let ctx = SimpleContext {
prefix: "TestPrefix".to_string(),
};
let (meta, tool) = ctx.get_prefix_definition()();
assert_eq!(meta.name, "get_prefix");
// Call with empty JSON object
let result = tool.execute(r#"{}"#).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(
output.contains("TestPrefix"),
"Should contain prefix: {}",
output
);
}
#[tokio::test]
async fn test_invalid_arguments() {
let ctx = SimpleContext {
prefix: "".to_string(),
};
let (_, tool) = ctx.greet_definition()();
// Invalid JSON
let result = tool.execute(r#"{"wrong_field": "value"}"#).await;
assert!(result.is_err(), "Should fail with invalid arguments");
}
// =============================================================================
// Test: Result Return Type
// =============================================================================
#[derive(Clone)]
struct FallibleContext;
#[derive(Debug)]
struct MyError(String);
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[tool_registry]
impl FallibleContext {
/// Validate the given value
#[tool]
async fn validate(&self, value: i32) -> Result<String, MyError> {
if value > 0 {
Ok(format!("Valid: {}", value))
} else {
Err(MyError("Value must be positive".to_string()))
}
}
}
#[tokio::test]
async fn test_result_return_type_success() {
let ctx = FallibleContext;
let (_, tool) = ctx.validate_definition()();
let result = tool.execute(r#"{"value": 42}"#).await;
assert!(result.is_ok(), "Should succeed for positive value");
let output = result.unwrap();
assert!(output.contains("Valid"), "Should contain Valid: {}", output);
}
#[tokio::test]
async fn test_result_return_type_error() {
let ctx = FallibleContext;
let (_, tool) = ctx.validate_definition()();
let result = tool.execute(r#"{"value": -1}"#).await;
assert!(result.is_err(), "Should fail for negative value");
let err = result.unwrap_err();
assert!(
err.to_string().contains("positive"),
"Error should mention positive: {}",
err
);
}
// =============================================================================
// Test: Synchronous Methods
// =============================================================================
#[derive(Clone)]
struct SyncContext {
counter: Arc<AtomicUsize>,
}
#[tool_registry]
impl SyncContext {
/// Increment counter and return (non-async)
#[tool]
fn increment(&self) -> usize {
self.counter.fetch_add(1, Ordering::SeqCst) + 1
}
}
#[tokio::test]
async fn test_sync_method() {
let ctx = SyncContext {
counter: Arc::new(AtomicUsize::new(0)),
};
let (_, tool) = ctx.increment_definition()();
// Execute 3 times
let result1 = tool.execute(r#"{}"#).await;
let result2 = tool.execute(r#"{}"#).await;
let result3 = tool.execute(r#"{}"#).await;
assert!(result1.is_ok());
assert!(result2.is_ok());
assert!(result3.is_ok());
// Counter should be 3
assert_eq!(ctx.counter.load(Ordering::SeqCst), 3);
}
// =============================================================================
// Test: ToolMeta Immutability
// =============================================================================
#[tokio::test]
async fn test_tool_meta_immutability() {
let ctx = SimpleContext {
prefix: "Test".to_string(),
};
// Verify same meta info is returned on multiple calls
let (meta1, _) = ctx.greet_definition()();
let (meta2, _) = ctx.greet_definition()();
assert_eq!(meta1.name, meta2.name);
assert_eq!(meta1.description, meta2.description);
assert_eq!(meta1.input_schema, meta2.input_schema);
}

View File

@ -0,0 +1,11 @@
use llm_worker::Worker;
use llm_worker::llm_client::providers::ollama::OllamaClient;
use std::sync::Arc;
fn main() {
let client = OllamaClient::new("dummy-model");
let worker = Worker::new(client);
let mut locked = worker.lock();
let def: llm_worker::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = locked.register_tool(def);
}

View File

@ -0,0 +1,8 @@
error[E0599]: no method named `register_tool` found for struct `Worker<OllamaClient, CacheLocked>` in the current scope
--> tests/ui/cache_locked_register_tool.rs:10:20
|
10 | let _ = locked.register_tool(def);
| ^^^^^^^^^^^^^ method not found in `Worker<OllamaClient, CacheLocked>`
|
= note: the method was found for
- `Worker<C>`

View File

@ -0,0 +1,11 @@
use llm_worker::Worker;
use llm_worker::llm_client::providers::ollama::OllamaClient;
use std::sync::Arc;
fn main() {
let client = OllamaClient::new("dummy-model");
let worker = Worker::new(client);
let handle = worker.tool_server_handle();
let def: llm_worker::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = handle.register_tool(def);
}

View File

@ -0,0 +1,13 @@
error[E0624]: method `register_tool` is private
--> tests/ui/tool_server_handle_register_tool.rs:10:20
|
10 | let _ = handle.register_tool(def);
| ^^^^^^^^^^^^^ private method
|
::: src/tool_server.rs
|
| / pub(crate) fn register_tool(
| | &self,
| | factory: WorkerToolDefinition,
| | ) -> Result<(), ToolServerError> {
| |____________________________________- private method defined here

View File

@ -0,0 +1,39 @@
use llm_worker::llm_client::providers::openai::OpenAIClient;
use llm_worker::{Worker, WorkerError};
#[test]
fn test_openai_top_k_warning() {
// Create client with dummy key (validate_config doesn't make network calls, so safe)
let client = OpenAIClient::new("dummy-key", "gpt-4o");
// Create Worker with top_k set (OpenAI doesn't support top_k)
let worker = Worker::new(client).top_k(50);
// Run validate()
let result = worker.validate();
// Verify error is returned and ConfigWarnings is included
match result {
Err(WorkerError::ConfigWarnings(warnings)) => {
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].option_name, "top_k");
println!("Got expected warning: {}", warnings[0]);
}
Ok(_) => panic!("Should have returned validation error"),
Err(e) => panic!("Unexpected error type: {:?}", e),
}
}
#[test]
fn test_openai_valid_config() {
let client = OpenAIClient::new("dummy-key", "gpt-4o");
// Valid configuration (temperature only)
let worker = Worker::new(client).temperature(0.7);
// Run validate()
let result = worker.validate();
// Verify success
assert!(result.is_ok());
}

View File

@ -0,0 +1,233 @@
//! Worker fixture-based integration tests
//!
//! Tests Worker behavior using recorded API responses.
//! Can run locally without API keys.
mod common;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use common::MockLlmClient;
use llm_worker::Worker;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
/// Fixture directory path
fn fixtures_dir() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/anthropic")
}
/// Simple test tool
#[derive(Clone)]
struct MockWeatherTool {
call_count: Arc<AtomicUsize>,
}
impl MockWeatherTool {
fn new() -> Self {
Self {
call_count: Arc::new(AtomicUsize::new(0)),
}
}
fn get_call_count(&self) -> usize {
self.call_count.load(Ordering::SeqCst)
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("get_weather")
.description("Get the current weather for a city")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
}
},
"required": ["city"]
}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for MockWeatherTool {
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
self.call_count.fetch_add(1, Ordering::SeqCst);
// Parse input
let input: serde_json::Value = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
let city = input["city"].as_str().unwrap_or("Unknown");
// Return mock response
Ok(format!("Weather in {}: Sunny, 22°C", city))
}
}
// =============================================================================
// Basic Fixture Tests
// =============================================================================
/// Verify that MockLlmClient can correctly load events from JSONL fixture files
///
/// Uses existing anthropic_*.jsonl files to verify events are parsed and loaded.
#[test]
fn test_mock_client_from_fixture() {
// Load existing fixture
let fixture_path = fixtures_dir().join("anthropic_1767624445.jsonl");
if !fixture_path.exists() {
println!("Fixture not found, skipping test");
return;
}
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
assert!(client.event_count() > 0, "Should have loaded events");
println!("Loaded {} events from fixture", client.event_count());
}
/// Verify that MockLlmClient works correctly with directly specified event lists
///
/// Creates a client with programmatically constructed events instead of using fixture files.
#[test]
fn test_mock_client_from_events() {
use llm_worker::llm_client::event::Event;
// Specify events directly
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Hello!"),
Event::text_block_stop(0, None),
];
let client = MockLlmClient::new(events);
assert_eq!(client.event_count(), 3);
}
// =============================================================================
// Worker Tests with Fixtures
// =============================================================================
/// Verify that Worker can correctly process simple text responses
///
/// Uses simple_text.jsonl fixture to test scenarios without tool calls.
/// Skipped if fixture is not present.
#[tokio::test]
async fn test_worker_simple_text_response() {
let fixture_path = fixtures_dir().join("simple_text.jsonl");
if !fixture_path.exists() {
println!("Fixture not found: {:?}, skipping test", fixture_path);
println!("Run: cargo run --example record_worker_test");
return;
}
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut worker = Worker::new(client);
// Send a simple message
let result = worker.run("Hello").await;
assert!(result.is_ok(), "Worker should complete successfully");
}
/// Verify that Worker can correctly process responses containing tool calls
///
/// Uses tool_call.jsonl fixture to test that MockWeatherTool is called.
/// Sets max_turns=1 to prevent loop after tool execution.
#[tokio::test]
async fn test_worker_tool_call() {
let fixture_path = fixtures_dir().join("tool_call.jsonl");
if !fixture_path.exists() {
println!("Fixture not found: {:?}, skipping test", fixture_path);
println!("Run: cargo run --example record_worker_test");
return;
}
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut worker = Worker::new(client);
// Register tool
let weather_tool = MockWeatherTool::new();
let tool_for_check = weather_tool.clone();
worker.register_tool(weather_tool.definition()).unwrap();
// Send message
let _result = worker.run("What's the weather in Tokyo?").await;
// Verify tool was called
// Note: max_turns=1 so no request is sent after tool result
let call_count = tool_for_check.get_call_count();
println!("Tool was called {} times", call_count);
// Tool should be called if fixture contains ToolUse
// But ends after 1 turn due to max_turns=1
}
/// Verify that Worker works without fixture files
///
/// Constructs event sequence programmatically and passes to MockLlmClient.
/// Useful when test independence is needed and external file dependency should be eliminated.
#[tokio::test]
async fn test_worker_with_programmatic_events() {
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
// Construct event sequence programmatically
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Hello, "),
Event::text_delta(0, "World!"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
let result = worker.run("Greet me").await;
assert!(result.is_ok(), "Worker should complete successfully");
}
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
///
/// Dispatches events to Timeline and verifies ToolCallCollector
/// correctly extracts id, name, and input (JSON).
#[tokio::test]
async fn test_tool_call_collector_integration() {
use llm_worker::llm_client::event::Event;
use llm_worker::timeline::{Timeline, ToolCallCollector};
// Event sequence containing ToolUse block
let events = vec![
Event::tool_use_start(0, "call_123", "get_weather"),
Event::tool_input_delta(0, r#"{"city":"#),
Event::tool_input_delta(0, r#""Tokyo"}"#),
Event::tool_use_stop(0),
];
let collector = ToolCallCollector::new();
let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone());
// Dispatch events
for event in &events {
let timeline_event: llm_worker::timeline::event::Event = event.clone().into();
timeline.dispatch(&timeline_event);
}
// Verify collected ToolCall
let calls = collector.take_collected();
assert_eq!(calls.len(), 1, "Should collect one tool call");
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].id, "call_123");
assert_eq!(calls[0].input["city"], "Tokyo");
}

View File

@ -0,0 +1,488 @@
//! Worker state management tests
//!
//! Tests for state transitions using the Type-state pattern (Mutable/CacheLocked)
//! and state preservation between turns.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use common::MockLlmClient;
use llm_worker::Item;
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
// =============================================================================
// Mutable State Tests
// =============================================================================
/// Verify that system prompt can be set in Mutable state
#[test]
fn test_mutable_set_system_prompt() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
assert!(worker.get_system_prompt().is_none());
worker.set_system_prompt("You are a helpful assistant.");
assert_eq!(
worker.get_system_prompt(),
Some("You are a helpful assistant.")
);
}
/// Verify that history can be freely edited in Mutable state
#[test]
fn test_mutable_history_manipulation() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
// Initial state is empty
assert!(worker.history().is_empty());
// Add to history
worker.push_item(Item::user_message("Hello"));
worker.push_item(Item::assistant_message("Hi there!"));
assert_eq!(worker.history().len(), 2);
// Mutable access to history
worker
.history_mut()
.push(Item::user_message("How are you?"));
assert_eq!(worker.history().len(), 3);
// Clear history
worker.clear_history();
assert!(worker.history().is_empty());
// Set history
let items = vec![
Item::user_message("Test"),
Item::assistant_message("Response"),
];
worker.set_history(items);
assert_eq!(worker.history().len(), 2);
}
/// Verify that Worker can be constructed using builder pattern
#[test]
fn test_mutable_builder_pattern() {
let client = MockLlmClient::new(vec![]);
let worker = Worker::new(client)
.system_prompt("System prompt")
.with_item(Item::user_message("Hello"))
.with_item(Item::assistant_message("Hi!"))
.with_items(vec![
Item::user_message("How are you?"),
Item::assistant_message("I'm fine!"),
]);
assert_eq!(worker.get_system_prompt(), Some("System prompt"));
assert_eq!(worker.history().len(), 4);
}
/// Verify that multiple items can be added with extend_history
#[test]
fn test_mutable_extend_history() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
worker.push_item(Item::user_message("First"));
worker.extend_history(vec![
Item::assistant_message("Response 1"),
Item::user_message("Second"),
Item::assistant_message("Response 2"),
]);
assert_eq!(worker.history().len(), 4);
}
#[derive(Clone)]
struct CountingTool {
name: String,
calls: Arc<AtomicUsize>,
}
impl CountingTool {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
(
ToolMeta::new(&tool.name)
.description("Counting tool")
.input_schema(serde_json::json!({"type":"object","properties":{}})),
Arc::new(tool.clone()) as Arc<dyn Tool>,
)
})
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl Tool for CountingTool {
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(format!("{}-ok", self.name))
}
}
/// Verify that tools can be registered in Mutable state.
#[test]
fn test_mutable_can_register_tool() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
let tool = CountingTool::new("count_tool");
let result = worker.register_tool(tool.definition());
assert!(result.is_ok(), "Mutable should allow tool registration");
}
// =============================================================================
// State Transition Tests
// =============================================================================
/// Verify that lock() transitions from Mutable -> CacheLocked state
#[test]
fn test_lock_transition() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
worker.set_system_prompt("System");
worker.push_item(Item::user_message("Hello"));
worker.push_item(Item::assistant_message("Hi"));
// Lock
let locked_worker = worker.lock();
// History and system prompt are still accessible in CacheLocked state
assert_eq!(locked_worker.get_system_prompt(), Some("System"));
assert_eq!(locked_worker.history().len(), 2);
assert_eq!(locked_worker.locked_prefix_len(), 2);
}
/// Verify that unlock() transitions from CacheLocked -> Mutable state
#[test]
fn test_unlock_transition() {
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
worker.push_item(Item::user_message("Hello"));
let locked_worker = worker.lock();
// Unlock
let mut worker = locked_worker.unlock();
// History operations are available again in Mutable state
worker.push_item(Item::assistant_message("Hi"));
worker.clear_history();
assert!(worker.history().is_empty());
}
// =============================================================================
// Turn Execution and State Preservation Tests
// =============================================================================
/// Verify that history is correctly updated after running a turn in Mutable state
#[tokio::test]
async fn test_mutable_run_updates_history() {
let events = vec![
Event::text_block_start(0),
Event::text_delta(0, "Hello, I'm an assistant!"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let mut worker = Worker::new(client);
// Execute
let result = worker.run("Hi there").await;
assert!(result.is_ok());
// History is updated
let history = worker.history();
assert_eq!(history.len(), 2); // user + assistant
// User message
assert_eq!(history[0].as_text(), Some("Hi there"));
// Assistant message
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!"));
}
/// Verify that history accumulates correctly over multiple turns in CacheLocked state
#[tokio::test]
async fn test_locked_multi_turn_history_accumulation() {
// Prepare responses for 2 requests
let client = MockLlmClient::with_responses(vec![
// First response
vec![
Event::text_block_start(0),
Event::text_delta(0, "Nice to meet you!"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
// Second response
vec![
Event::text_block_start(0),
Event::text_delta(0, "I can help with that."),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let worker = Worker::new(client).system_prompt("You are helpful.");
// Lock (after setting system prompt)
let mut locked_worker = worker.lock();
assert_eq!(locked_worker.locked_prefix_len(), 0); // No items yet
// Turn 1
let result1 = locked_worker.run("Hello!").await;
assert!(result1.is_ok());
assert_eq!(locked_worker.history().len(), 2); // user + assistant
// Turn 2
let result2 = locked_worker.run("Can you help me?").await;
assert!(result2.is_ok());
assert_eq!(locked_worker.history().len(), 4); // 2 * (user + assistant)
// Verify history contents
let history = locked_worker.history();
// Turn 1 user message
assert_eq!(history[0].as_text(), Some("Hello!"));
// Turn 1 assistant message
assert_eq!(history[1].as_text(), Some("Nice to meet you!"));
// Turn 2 user message
assert_eq!(history[2].as_text(), Some("Can you help me?"));
// Turn 2 assistant message
assert_eq!(history[3].as_text(), Some("I can help with that."));
}
/// Verify that locked_prefix_len correctly records history length at lock time
#[tokio::test]
async fn test_locked_prefix_len_tracking() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::text_block_start(0),
Event::text_delta(0, "Response 1"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Response 2"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut worker = Worker::new(client);
// Add items beforehand
worker.push_item(Item::user_message("Pre-existing message 1"));
worker.push_item(Item::assistant_message("Pre-existing response 1"));
assert_eq!(worker.history().len(), 2);
// Lock
let mut locked_worker = worker.lock();
assert_eq!(locked_worker.locked_prefix_len(), 2); // 2 items at lock time
// Execute turn
locked_worker.run("New message").await.unwrap();
// History grows but locked_prefix_len remains unchanged
assert_eq!(locked_worker.history().len(), 4); // 2 + 2
assert_eq!(locked_worker.locked_prefix_len(), 2); // Unchanged
}
/// Verify that turn count is correctly incremented
#[tokio::test]
async fn test_turn_count_increment() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::text_block_start(0),
Event::text_delta(0, "Turn 1"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Turn 2"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut worker = Worker::new(client);
assert_eq!(worker.turn_count(), 0);
worker.run("First").await.unwrap();
assert_eq!(worker.turn_count(), 1);
worker.run("Second").await.unwrap();
assert_eq!(worker.turn_count(), 2);
}
/// Verify that history can be edited after unlock and re-locked
#[tokio::test]
async fn test_unlock_edit_relock() {
let client = MockLlmClient::with_responses(vec![vec![
Event::text_block_start(0),
Event::text_delta(0, "Response"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let worker = Worker::new(client)
.with_item(Item::user_message("Hello"))
.with_item(Item::assistant_message("Hi"));
// Lock -> Unlock
let locked = worker.lock();
assert_eq!(locked.locked_prefix_len(), 2);
let mut unlocked = locked.unlock();
// Edit history
unlocked.clear_history();
unlocked.push_item(Item::user_message("Fresh start"));
// Re-lock
let relocked = unlocked.lock();
assert_eq!(relocked.history().len(), 1);
assert_eq!(relocked.locked_prefix_len(), 1);
}
/// Verify that tools registered before lock and after unlock remain effective.
#[tokio::test]
async fn test_lock_unlock_relock_tools_remain_effective() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_1", "tool_a"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "done-a"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::tool_use_start(0, "call_2", "tool_b"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "done-b"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut worker = Worker::new(client);
let tool_a = CountingTool::new("tool_a");
worker
.register_tool(tool_a.definition())
.expect("register tool_a should succeed");
let mut locked = worker.lock();
locked.run("first").await.expect("first run");
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
let mut unlocked = locked.unlock();
let tool_b = CountingTool::new("tool_b");
unlocked
.register_tool(tool_b.definition())
.expect("register tool_b after unlock should succeed");
let mut relocked = unlocked.lock();
relocked.run("second").await.expect("second run");
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
}
// =============================================================================
// System Prompt Preservation Tests
// =============================================================================
/// Verify that system prompt is preserved in CacheLocked state
#[test]
fn test_system_prompt_preserved_in_locked_state() {
let client = MockLlmClient::new(vec![]);
let worker = Worker::new(client).system_prompt("Important system prompt");
let locked = worker.lock();
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
let unlocked = locked.unlock();
assert_eq!(
unlocked.get_system_prompt(),
Some("Important system prompt")
);
}
/// Verify that system prompt can be changed after unlock -> re-lock
#[test]
fn test_system_prompt_change_after_unlock() {
let client = MockLlmClient::new(vec![]);
let worker = Worker::new(client).system_prompt("Original prompt");
let locked = worker.lock();
let mut unlocked = locked.unlock();
unlocked.set_system_prompt("New prompt");
assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
let relocked = unlocked.lock();
assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
}

10
devshell.nix Normal file
View File

@ -0,0 +1,10 @@
{ pkgs }:
pkgs.mkShell {
buildInputs = with pkgs; [
rustc
cargo
];
shellHook = ''
echo "dev-shell-loaded"
'';
}

61
flake.lock Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

26
flake.nix Normal file
View File

@ -0,0 +1,26 @@
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages.default = pkgs.callPackage ./package.nix { };
devShells.default = import ./devshell.nix { inherit pkgs; };
}
);
}

0
package.nix Normal file
View File