Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ These three Microsoft offerings are distinct and must not all be called "Azure".

## Protocol terms

- **Model Turn** — one contiguous segment of model output. A model turn may end
while the dialog interaction remains active because the provider is still
reasoning or waiting for asynchronous tool results.

- **Dialog Interaction** — processing initiated by user input that may span
multiple model turns and tool calls. It completes only when the provider reports
that no reasoning, generation, or tool work remains.

- **Interaction In Progress** — a model-turn boundary at which the dialog
interaction remains active. More tool calls or model output may follow without
new user input.

- **Interaction Idle** — the terminal state of a dialog interaction. No reasoning,
generation, or tool work remains, and new model output requires new client input.

- **Initial Start Message** — the first WebSocket message for a conversation. It
either contains complete service parameters or declares that they will follow
in a deferred params message.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ members = [
]

[workspace.package]
version = "3.8.1"
version = "3.9.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/pragmatrix/context-switch"
Expand Down
1 change: 1 addition & 0 deletions VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ root `Cargo.toml`); all member crates inherit it via `version.workspace = true`.

| Version | Change |
|---------|--------|
| 3.9.0 | google-dialog: Gemini 3.8 Live models and interaction semantics |
| 3.8.0 | google-transcribe: numerals support via digit-sequence class token (#99) |
| 3.8.1 | audio-knife: startup self-test warning for `GOOGLE_APPLICATION_CREDENTIALS` (#100) |
524 changes: 524 additions & 0 deletions docs/adr/0006-gemini-3-8-live-model-semantics.md

Large diffs are not rendered by default.

132 changes: 123 additions & 9 deletions examples/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ struct Cli {
/// Used only with provider `google-agent-platform`.
#[arg(long)]
location: Option<String>,
/// Override whether server-side input transcription is enabled. Defaults to
/// off for providers that enable it by default.
#[arg(long)]
input_transcription: Option<bool>,
/// Override whether server-side output transcription is enabled. Defaults to
/// off for providers that enable it by default.
#[arg(long)]
output_transcription: Option<bool>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
Expand All @@ -63,6 +71,26 @@ impl Provider {
fn api(self) -> &'static dyn dialog_providers::ProviderApi {
dialog_providers::provider_api(self)
}

/// Text input commands supported by the provider (see `send_input_line`).
fn capabilities(self) -> ProviderCapabilities {
let mut capabilities = ProviderCapabilities::default();

match self {
Provider::OpenAI | Provider::AzureOpenAI => {}
Provider::Google | Provider::GoogleAgentPlatform => {
capabilities.client_content = true;
}
}

capabilities
}
}

#[derive(Debug, Clone, Copy, Default)]
struct ProviderCapabilities {
/// The provider service accepts `clientContent` service input events.
client_content: bool,
}

#[tokio::main]
Expand Down Expand Up @@ -91,6 +119,7 @@ async fn main() -> Result<()> {
.expect("Failed to get default input config");

println!("Audio device input config: {input_config:?}");
print_usage_hint();

let channels = input_config.channels();
let sample_rate = input_config.sample_rate();
Expand Down Expand Up @@ -200,7 +229,7 @@ async fn main() -> Result<()> {
line = stdin_lines.next_line(), if !stdin_closed => {
match line? {
Some(line) => {
send_prompt_line(&input_sender, &line).await?;
send_input_line(cli.provider, &input_sender, &line).await?;
}
None => {
stdin_closed = true;
Expand Down Expand Up @@ -261,21 +290,104 @@ fn setup_audio_input_adapter(
sender
}

async fn send_prompt_line(input: &Sender<Input>, line: &str) -> Result<()> {
let prompt = line.trim();
if prompt.is_empty() {
async fn send_input_line(provider: Provider, input: &Sender<Input>, line: &str) -> Result<()> {
let line = line.trim();
if line.is_empty() {
return Ok(());
}

let event = parse_input_line(line);
let Some(event) = event else {
print_usage_hint();
return Ok(());
};

if matches!(event, InputEvent::ClientContent { .. }) && !provider.capabilities().client_content
{
println!(
"Provider '{}' does not support clientContent commands",
provider
.to_possible_value()
.expect("Provider has a possible value")
.get_name()
);
return Ok(());
}

input
.send(Input::ServiceEvent {
value: json!({ "type": "prompt", "text": prompt }),
})
.await?;
let value = match event {
InputEvent::Prompt { text } => json!({ "type": "prompt", "text": text }),
InputEvent::ClientContent {
role,
text,
turn_complete,
} => json!({
"type": "clientContent",
"role": role,
"text": text,
"turnComplete": turn_complete,
}),
};

input.send(Input::ServiceEvent { value }).await?;

Ok(())
}

enum InputEvent {
Prompt {
text: String,
},
ClientContent {
role: &'static str,
text: String,
turn_complete: bool,
},
}

/// Parses one stdin line into a service input event.
///
/// Grammar (first word decides):
/// - `prompt <text>` — realtime prompt.
/// - `user <text>` / `agent <text>` — clientContent for the user/model role.
/// A single trailing `!` on the text is stripped and sets
/// `turnComplete: true` ("respond now"); `user !` sends empty text with
/// `turnComplete: true`. Without it, content is added silently. A bare
/// `user`/`agent` sends empty text, silent.
///
/// Returns `None` for a line without text after the command word; the caller
/// prints the usage hint.
fn parse_input_line(line: &str) -> Option<InputEvent> {
let (command, rest) = match line.split_once(' ') {
Some((command, rest)) => (command, rest.trim()),
None => (line, ""),
};

match command {
"prompt" if !rest.is_empty() => Some(InputEvent::Prompt { text: rest.into() }),
"user" | "agent" => {
let role = if command == "user" { "user" } else { "model" };
let turn_complete = rest.ends_with('!');
Some(InputEvent::ClientContent {
role,
text: rest.strip_suffix('!').unwrap_or(rest).into(),
turn_complete,
})
}
_ => None,
}
}

fn print_usage_hint() {
println!(
"Commands, one per line:\n\
prompt <text>\n\
user <text>[!]\n\
agent <text>[!]\n\
a single trailing ! completes the turn now, otherwise content is added silently;\n\
lines without a prompt/user/agent first word print this hint"
);
}

fn list_available_voices(provider: Provider) -> Result<()> {
println!("Available voices for {:?}:", provider);
for voice in provider.api().voices() {
Expand All @@ -299,6 +411,8 @@ async fn start_conversation(cli: &Cli, conversation: Conversation) -> Result<()>
voice: cli.voice.clone(),
project: cli.project.clone(),
location: cli.location.clone(),
input_transcription: cli.input_transcription,
output_transcription: cli.output_transcription,
};
cli.provider
.api()
Expand Down
20 changes: 14 additions & 6 deletions examples/dialog_providers/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use gemini_live::types as gemini_types;
use google_dialog::{
GoogleDialog, ServiceInputEvent as GoogleServiceInputEvent, ServiceOutputEvent,
GEMINI_3_8_LIVE, GoogleDialog, ServiceInputEvent as GoogleServiceInputEvent, ServiceOutputEvent,
};
use reqwest::Url;
use serde::Deserialize;
Expand All @@ -29,7 +29,7 @@ impl ProviderApi for GoogleProvider {
.model
.or_else(|| env::var("GEMINI_LIVE_API_MODEL").ok())
.filter(|model| !model.trim().is_empty())
.unwrap_or_else(|| "gemini-3.1-flash-live-preview".to_owned());
.unwrap_or_else(|| GEMINI_3_8_LIVE.to_owned());

let mut params = google_dialog::Params::new(model);
params.api_key = Some(key);
Expand All @@ -42,8 +42,8 @@ impl ProviderApi for GoogleProvider {
.as_deref()
.map(google_dialog::parse_voice_value)
.transpose()?;
params.input_audio_transcription = true;
params.output_audio_transcription = true;
params.input_audio_transcription = request.input_transcription.unwrap_or_default();
params.output_audio_transcription = request.output_transcription.unwrap_or_default();
params.tools.push(get_time_tool());

GoogleDialog.conversation(params, conversation).await
Expand All @@ -64,6 +64,10 @@ impl ProviderApi for GoogleProvider {
tracing::info!("Turn complete");
Ok(None)
}
ServiceOutputEvent::InteractionInProgress => {
tracing::info!("Interaction remains in progress");
Ok(None)
}
ServiceOutputEvent::ToolCallCancellation { call_id } => {
tracing::info!("Tool call cancelled: {call_id}");
Ok(None)
Expand All @@ -73,8 +77,12 @@ impl ProviderApi for GoogleProvider {

fn function_result_event(&self, call_id: String, result: String) -> Result<serde_json::Value> {
let output = json!({ "time": serde_json::Value::String(result) });
serde_json::to_value(&GoogleServiceInputEvent::FunctionCallResult { call_id, output })
.map_err(Into::into)
serde_json::to_value(&GoogleServiceInputEvent::FunctionCallResult {
call_id,
output,
scheduling: None,
})
.map_err(Into::into)
}

fn output_format(&self, _input_format: AudioFormat) -> AudioFormat {
Expand Down
16 changes: 12 additions & 4 deletions examples/dialog_providers/google_agent_platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ impl ProviderApi for GoogleAgentPlatformProvider {
.as_deref()
.map(google_dialog::parse_voice_value)
.transpose()?;
params.input_audio_transcription = true;
params.output_audio_transcription = true;
params.input_audio_transcription = request.input_transcription.unwrap_or_default();
params.output_audio_transcription = request.output_transcription.unwrap_or_default();
params.tools.push(get_time_tool());

GoogleDialog.conversation(params, conversation).await
Expand All @@ -79,6 +79,10 @@ impl ProviderApi for GoogleAgentPlatformProvider {
tracing::info!("Turn complete");
Ok(None)
}
ServiceOutputEvent::InteractionInProgress => {
tracing::info!("Interaction remains in progress");
Ok(None)
}
ServiceOutputEvent::ToolCallCancellation { call_id } => {
tracing::info!("Tool call cancelled: {call_id}");
Ok(None)
Expand All @@ -88,8 +92,12 @@ impl ProviderApi for GoogleAgentPlatformProvider {

fn function_result_event(&self, call_id: String, result: String) -> Result<serde_json::Value> {
let output = json!({ "time": serde_json::Value::String(result) });
serde_json::to_value(&GoogleServiceInputEvent::FunctionCallResult { call_id, output })
.map_err(Into::into)
serde_json::to_value(&GoogleServiceInputEvent::FunctionCallResult {
call_id,
output,
scheduling: None,
})
.map_err(Into::into)
}

fn output_format(&self, _input_format: AudioFormat) -> AudioFormat {
Expand Down
4 changes: 4 additions & 0 deletions examples/dialog_providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ pub struct StartConversationRequest {
pub voice: Option<String>,
pub project: Option<String>,
pub location: Option<String>,
/// Command-line transcription overrides; `Ok`/`Err` explicit on/off,
/// `None` keeps the provider default.
pub input_transcription: Option<bool>,
pub output_transcription: Option<bool>,
}

#[async_trait(?Send)]
Expand Down
4 changes: 2 additions & 2 deletions examples/dialog_providers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ impl ProviderApi for OpenAIProvider {
.map(parse_realtime_voice_value)
.transpose()?;
params.tools.push(get_time_function_definition());
params.input_audio_transcription = true;
params.output_audio_transcription = true;
params.input_audio_transcription = request.input_transcription.unwrap_or_default();
params.output_audio_transcription = request.output_transcription.unwrap_or_default();

OpenAIDialog.conversation(params, conversation).await
}
Expand Down
2 changes: 1 addition & 1 deletion external/gemini-live-rs
Submodule gemini-live-rs updated 61 files
+4 −0 .gitignore
+80 −95 Cargo.lock
+5 −5 Cargo.toml
+2 −1 README.md
+3 −2 crates/gemini-live-cli/Cargo.toml
+5 −1 crates/gemini-live-cli/src/app.rs
+15 −0 crates/gemini-live-cli/src/main.rs
+46 −1 crates/gemini-live-cli/src/profile.rs
+3 −5 crates/gemini-live-cli/src/render.rs
+28 −3 crates/gemini-live-cli/src/startup.rs
+3 −0 crates/gemini-live-cli/src/tooling.rs
+155 −0 crates/gemini-live-cli/src/transcribe/audio.rs
+562 −0 crates/gemini-live-cli/src/transcribe/mod.rs
+247 −0 crates/gemini-live-cli/src/transcribe/render.rs
+451 −0 crates/gemini-live-cli/src/transcribe/slash.rs
+374 −0 crates/gemini-live-cli/src/transcribe/startup.rs
+311 −0 crates/gemini-live-cli/src/transcribe/state.rs
+1 −1 crates/gemini-live-discord/Cargo.toml
+12 −1 crates/gemini-live-discord/src/service.rs
+2 −2 crates/gemini-live-discord/src/session.rs
+3 −0 crates/gemini-live-discord/src/setup.rs
+3 −2 crates/gemini-live-discord/src/voice.rs
+1 −1 crates/gemini-live-harness/Cargo.toml
+17 −0 crates/gemini-live-harness/src/adapter.rs
+3 −2 crates/gemini-live-harness/src/bridge.rs
+11 −7 crates/gemini-live-harness/src/controller.rs
+10 −6 crates/gemini-live-harness/src/executor.rs
+3 −2 crates/gemini-live-io/Cargo.toml
+8 −0 crates/gemini-live-io/src/audio/captured.rs
+143 −102 crates/gemini-live-io/src/audio/mic.rs
+13 −2 crates/gemini-live-io/src/audio/mod.rs
+100 −0 crates/gemini-live-io/src/audio/pcm.rs
+4 −3 crates/gemini-live-io/src/audio/speaker.rs
+191 −0 crates/gemini-live-io/src/audio/system.rs
+10 −5 crates/gemini-live-io/src/lib.rs
+10 −2 crates/gemini-live-io/src/screen/capture.rs
+1 −1 crates/gemini-live-io/src/screen/mod.rs
+8 −0 crates/gemini-live-io/src/screen/target.rs
+24 −0 crates/gemini-live-recorder/Cargo.toml
+1,116 −0 crates/gemini-live-recorder/src/main.rs
+68 −0 crates/gemini-live-recorder/src/prompt.rs
+185 −0 crates/gemini-live-recorder/src/store.rs
+154 −0 crates/gemini-live-recorder/src/tool.rs
+1 −1 crates/gemini-live-runtime/Cargo.toml
+1 −1 crates/gemini-live-runtime/benches/managed_runtime.rs
+4 −3 crates/gemini-live-runtime/src/managed.rs
+1 −1 crates/gemini-live-tools/Cargo.toml
+3 −1 crates/gemini-live-tools/src/timer.rs
+2 −0 crates/gemini-live-tools/src/workspace.rs
+1 −1 crates/gemini-live/Cargo.toml
+153 −8 crates/gemini-live/src/codec.rs
+2 −0 crates/gemini-live/src/error.rs
+158 −46 crates/gemini-live/src/session.rs
+9 −1 crates/gemini-live/src/types/client_message.rs
+121 −2 crates/gemini-live/src/types/config.rs
+29 −2 crates/gemini-live/src/types/server_message.rs
+41 −0 docs/cli.md
+1 −1 docs/design.md
+8 −2 docs/protocol.md
+3 −0 docs/roadmap.md
+13 −7 docs/testing.md
Loading
Loading