P2c: in-app choir agent with proposals and approval

This commit is contained in:
Emil
2026-11-01 02:27:44 +03:00
parent 0df07577a1
commit 2ecb8822de
20 changed files with 2236 additions and 30 deletions
+1
View File
@@ -27,3 +27,4 @@ clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
hound = "3"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+440
View File
@@ -0,0 +1,440 @@
//! The choir: an LLM performer on the same verbs as the hand.
//!
//! The model never touches the session directly. It returns tool calls; safe
//! performance verbs execute immediately, project edits become proposals that
//! the hand approves. With no key (or ZHILA_AGENT_MOCK=1) a deterministic mock
//! keeps the whole path testable.
use std::collections::HashMap;
use std::time::Duration;
use serde_json::{Value, json};
use zhila_protocol::{AgentMessage, AgentRole, Command, MeterFrame, State, VeinId};
#[derive(Clone)]
pub struct AgentConfig {
key: Option<String>,
base_url: String,
model: String,
mock: bool,
}
impl AgentConfig {
pub fn from_env() -> Self {
let key = std::env::var("ZHILA_AGENT_KEY")
.ok()
.or_else(|| std::env::var("DEEPSEEK_API_KEY").ok())
.filter(|value| !value.trim().is_empty());
let mock = std::env::var("ZHILA_AGENT_MOCK").is_ok() || key.is_none();
Self {
key,
base_url: std::env::var("ZHILA_AGENT_URL")
.unwrap_or_else(|_| "https://api.deepseek.com".into()),
model: std::env::var("ZHILA_AGENT_MODEL").unwrap_or_else(|_| "deepseek-chat".into()),
mock,
}
}
pub fn is_mock(&self) -> bool {
self.mock
}
}
#[derive(Clone)]
pub struct Proposal {
pub id: u64,
pub description: String,
pub commands: Vec<Command>,
}
pub struct Turn {
pub reply: String,
pub immediate: Vec<Command>,
pub proposal: Option<Proposal>,
}
pub struct Agent {
config: AgentConfig,
client: reqwest::Client,
messages: Vec<AgentMessage>,
pending: HashMap<u64, Proposal>,
next_id: u64,
}
impl Agent {
pub fn new(config: AgentConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("agent http client");
Self {
config,
client,
messages: Vec::new(),
pending: HashMap::new(),
next_id: 0,
}
}
pub fn messages(&self) -> &[AgentMessage] {
&self.messages
}
pub async fn ask(&mut self, text: &str, context: &str) -> Turn {
self.messages.push(AgentMessage {
role: AgentRole::User,
text: text.to_string(),
});
let (reply, calls) = if self.config.mock {
mock_turn(text)
} else {
match self.llm(text, context).await {
Ok(result) => result,
Err(error) => (
format!("The provider is not answering: {error}. The room is still yours."),
Vec::new(),
),
}
};
self.messages.push(AgentMessage {
role: AgentRole::Agent,
text: reply.clone(),
});
let mut immediate = Vec::new();
let mut project = Vec::new();
for command in calls {
if is_safe(&command) {
immediate.push(command);
} else {
project.push(command);
}
}
let proposal = if project.is_empty() {
None
} else {
self.next_id += 1;
let proposal = Proposal {
id: self.next_id,
description: reply.clone(),
commands: project,
};
self.pending.insert(proposal.id, proposal.clone());
Some(proposal)
};
Turn {
reply,
immediate,
proposal,
}
}
pub fn take(&mut self, id: u64) -> Option<Proposal> {
self.pending.remove(&id)
}
pub fn note(&mut self, text: impl Into<String>) {
self.messages.push(AgentMessage {
role: AgentRole::Agent,
text: text.into(),
});
}
pub fn reset(&mut self) {
self.messages.clear();
self.pending.clear();
}
async fn llm(&self, text: &str, context: &str) -> anyhow::Result<(String, Vec<Command>)> {
let key = self
.config
.key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no agent key"))?;
let mut messages = vec![json!({
"role": "system",
"content": system_prompt(context),
})];
for message in self.messages.iter().rev().take(12).rev() {
let role = match message.role {
AgentRole::User => "user",
AgentRole::Agent => "assistant",
AgentRole::System => "system",
};
messages.push(json!({ "role": role, "content": message.text }));
}
let body = json!({
"model": self.config.model,
"messages": messages,
"tools": tools(),
"tool_choice": "auto",
"temperature": 0.7,
"max_tokens": 500,
});
let response = self
.client
.post(format!(
"{}/chat/completions",
self.config.base_url.trim_end_matches('/')
))
.bearer_auth(key)
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("{status}: {text}");
}
let value: Value = response.json().await?;
let message = &value["choices"][0]["message"];
let mut content = message["content"].as_str().unwrap_or("").trim().to_string();
let mut commands = Vec::new();
if let Some(calls) = message["tool_calls"].as_array() {
for call in calls {
if let Some(command) = command_from_tool(
call["function"]["name"].as_str().unwrap_or_default(),
call["function"]["arguments"].as_str().unwrap_or("{}"),
) {
commands.push(command);
}
}
}
if content.is_empty() {
content = if commands.is_empty() {
"I am listening.".into()
} else {
"I would move these.".into()
};
}
let _ = text;
Ok((content, commands))
}
}
fn is_safe(command: &Command) -> bool {
matches!(
command,
Command::Play
| Command::Stop
| Command::Strike { .. }
| Command::StrikeFace { .. }
| Command::Render
)
}
fn mock_turn(text: &str) -> (String, Vec<Command>) {
let lower = text.to_lowercase();
if lower.contains("tempo") || lower.contains("faster") || lower.contains("быстр") {
(
"A little more forward. I would take the room to 132 bpm and let the low end breathe."
.into(),
vec![Command::SetTempo { bpm: 132.0 }],
)
} else if lower.contains("play") {
("Opening the loop.".into(), vec![Command::Play])
} else if lower.contains("stop") {
("Stopping on the stone.".into(), vec![Command::Stop])
} else if lower.contains("render") {
(
"Rendering the eight bars to disk.".into(),
vec![Command::Render],
)
} else if lower.contains("listen") || lower.contains("hear") || lower.contains("слыш") {
(
"The low end holds the room; the high seam is thin. I would strike the bass face once and let it ring."
.into(),
vec![Command::Strike {
vein: VeinId::Bass,
velocity: 0.82,
}],
)
} else if lower.contains("bass") || lower.contains("бас") {
(
"The bass wants a little more weight; I would lift it to 0.9.".into(),
vec![Command::SetVeinGain {
vein: VeinId::Bass,
gain: 0.9,
}],
)
} else if lower.contains("section") || lower.contains("секци") {
(
"Section B is where the room turns; I would open it.".into(),
vec![Command::SelectSection { index: 1 }],
)
} else if lower.contains("render") {
("Rendering the arrangement.".into(), vec![Command::Render])
} else {
(
"I am here, listening to the room. Ask me to move something, or press listen.".into(),
Vec::new(),
)
}
}
fn system_prompt(context: &str) -> String {
format!(
"You are the choir of ZHILA: a second performer in a sampling instrument where sound is terrain. \
You speak in short, concrete sentences and you listen to the current room, not to a generic idea of it. \
You have the same verbs as the hand.\n\
Project edits (tempo, gains, pattern steps, faces, sections, cuts, undo, redo) are proposals: call the tool, and the hand will approve or reject. \
Performance (play, stop, strike, strike_face, render) is immediate. Never invent cores, faces or sections that are not in the context. \
Do not ask for a plan; move one or two things at a time. If the user asks to listen, answer with what you hear in the levels and propose a small move.\n\nContext:\n{context}"
)
}
pub fn describe(state: &State, meter: &MeterFrame) -> String {
let mut text = format!(
"project {} · {:.0} bpm · revision {} · {:.0} bars\nsections: ",
state.project_name, state.transport.bpm, state.revision, state.transport.loop_bars
);
for (index, section) in state.arrangement.iter().enumerate() {
let notes: usize = section
.rows
.iter()
.map(|row| row.velocities.iter().filter(|value| **value > 0).count())
.sum();
let faces: usize = section.rows.iter().filter(|row| row.face.is_some()).count();
let letter = (b'A' + index as u8) as char;
let active = if index == state.active_section as usize {
"*"
} else {
""
};
text.push_str(&format!("{letter}{active}({notes} notes, {faces} faces) "));
}
text.push('\n');
text.push_str("veins: ");
for vein in &state.veins {
let level = meter.veins.get(vein.id.index()).copied().unwrap_or(0.0);
text.push_str(&format!(
"{} gain {:.2} level {:.2}; ",
vein.name, vein.gain, level
));
}
text.push_str(&format!(
"\nmaster {:.3} · bar {:.2} · playing {}\n",
meter.master, meter.bar, state.transport.playing
));
for core in &state.cores {
let top = core
.faces
.iter()
.find(|face| face.rank == 0)
.map(|face| face.index + 1)
.unwrap_or(0);
text.push_str(&format!(
"core {} (seal {:?}): {} faces, top f{}, duration {:.2}s\n",
core.name,
core.seal,
core.faces.len(),
top,
core.duration_sec
));
}
text
}
fn tools() -> Value {
json!([
{"type":"function","function":{"name":"play","description":"Start the arrangement.","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"stop","description":"Stop the arrangement.","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"render","description":"Render the current arrangement to a WAV.","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"strike","description":"Strike a vein once.","parameters":{"type":"object","properties":{"vein":{"type":"string","enum":["bass","crack","voice","noise","silt"]},"velocity":{"type":"number"}},"required":["vein"]}}},
{"type":"function","function":{"name":"strike_face","description":"Audition a face of a core.","parameters":{"type":"object","properties":{"core":{"type":"string"},"face":{"type":"integer"},"velocity":{"type":"number"}},"required":["core","face"]}}},
{"type":"function","function":{"name":"set_tempo","description":"Propose a tempo (40–220 bpm).","parameters":{"type":"object","properties":{"bpm":{"type":"number"}},"required":["bpm"]}}},
{"type":"function","function":{"name":"set_vein_gain","description":"Propose a vein gain (0–1.2).","parameters":{"type":"object","properties":{"vein":{"type":"string","enum":["bass","crack","voice","noise","silt"]},"gain":{"type":"number"}},"required":["vein","gain"]}}},
{"type":"function","function":{"name":"set_pattern_step","description":"Propose a step velocity (0–127) on a pattern row (0–4) of the active section.","parameters":{"type":"object","properties":{"row":{"type":"integer"},"step":{"type":"integer"},"velocity":{"type":"integer"}},"required":["row","step","velocity"]}}},
{"type":"function","function":{"name":"set_pattern_face","description":"Propose laying a core face on a row of the active section.","parameters":{"type":"object","properties":{"row":{"type":"integer"},"core":{"type":"string"},"face":{"type":"integer"}},"required":["row","core","face"]}}},
{"type":"function","function":{"name":"select_section","description":"Propose selecting a section (0–3).","parameters":{"type":"object","properties":{"index":{"type":"integer"}},"required":["index"]}}},
{"type":"function","function":{"name":"copy_section","description":"Propose copying a section.","parameters":{"type":"object","properties":{"from":{"type":"integer"},"to":{"type":"integer"}},"required":["from","to"]}}},
{"type":"function","function":{"name":"clear_section","description":"Propose clearing a section.","parameters":{"type":"object","properties":{"index":{"type":"integer"}},"required":["index"]}}},
{"type":"function","function":{"name":"set_core_faces","description":"Propose new face boundaries (17 seconds, ascending) for a core.","parameters":{"type":"object","properties":{"core":{"type":"string"},"boundaries":{"type":"array","items":{"type":"number"}}},"required":["core","boundaries"]}}},
{"type":"function","function":{"name":"undo","description":"Propose undo.","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"redo","description":"Propose redo.","parameters":{"type":"object","properties":{},"additionalProperties":false}}}
])
}
fn command_from_tool(name: &str, arguments: &str) -> Option<Command> {
let args: Value = serde_json::from_str(arguments).unwrap_or(Value::Null);
let vein = |value: &Value| serde_json::from_value::<VeinId>(value.clone()).ok();
let subject = |value: &Value| serde_json::from_value::<VeinId>(value.clone()).ok();
let _ = subject;
match name {
"play" => Some(Command::Play),
"stop" => Some(Command::Stop),
"render" => Some(Command::Render),
"strike" => Some(Command::Strike {
vein: vein(&args["vein"])?,
velocity: args["velocity"].as_f64().unwrap_or(0.8) as f32,
}),
"strike_face" => Some(Command::StrikeFace {
core: args["core"].as_str()?.to_string(),
face: args["face"].as_u64()? as u8,
velocity: args["velocity"].as_f64().unwrap_or(0.8) as f32,
}),
"set_tempo" => Some(Command::SetTempo {
bpm: args["bpm"].as_f64()? as f32,
}),
"set_vein_gain" => Some(Command::SetVeinGain {
vein: vein(&args["vein"])?,
gain: args["gain"].as_f64()? as f32,
}),
"set_pattern_step" => Some(Command::SetPatternStep {
row: args["row"].as_u64()? as u8,
step: args["step"].as_u64()? as u8,
velocity: args["velocity"].as_u64()? as u8,
}),
"set_pattern_face" => Some(Command::SetPatternFace {
row: args["row"].as_u64()? as u8,
core: args["core"].as_str()?.to_string(),
face: args["face"].as_u64()? as u8,
}),
"select_section" => Some(Command::SelectSection {
index: args["index"].as_u64()? as u8,
}),
"copy_section" => Some(Command::CopySection {
from: args["from"].as_u64()? as u8,
to: args["to"].as_u64()? as u8,
}),
"clear_section" => Some(Command::ClearSection {
index: args["index"].as_u64()? as u8,
}),
"set_core_faces" => Some(Command::SetCoreFaces {
core: args["core"].as_str()?.to_string(),
boundaries: serde_json::from_value(args["boundaries"].clone()).ok()?,
}),
"undo" => Some(Command::Undo),
"redo" => Some(Command::Redo),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_proposes_tempo_and_plays() {
let (reply, commands) = mock_turn("raise the tempo");
assert!(reply.to_lowercase().contains("132"));
assert!(matches!(commands.as_slice(), [Command::SetTempo { .. }]));
let (_, commands) = mock_turn("play");
assert!(matches!(commands.as_slice(), [Command::Play]));
}
#[test]
fn tool_calls_become_commands() {
assert!(matches!(
command_from_tool("set_tempo", r#"{"bpm":132}"#),
Some(Command::SetTempo { bpm }) if bpm == 132.0
));
assert!(matches!(
command_from_tool("strike", r#"{"vein":"bass","velocity":0.7}"#),
Some(Command::Strike {
vein: VeinId::Bass,
..
})
));
assert!(command_from_tool("nonsense", "{}").is_none());
}
}
+245 -3
View File
@@ -1,8 +1,10 @@
//! ZHILA local daemon: one WebSocket endpoint, one session, one engine.
//! ZHILA local host: one WebSocket endpoint, one session, one engine, one choir.
//!
//! Text frames are JSON control messages; meter frames are binary. The daemon
//! binds to loopback only and requires a token in the hello.
mod agent;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -21,8 +23,8 @@ use zhila_core::{CoreError, Engine, MaterialSnapshot, Session, default_pattern};
use zhila_engine::{AudioEngine, NullEngine, RenderRequest, render_arrangement};
use zhila_project::{ProjectSnapshot, ProjectStore};
use zhila_protocol::{
Actor, ClientMessage, Command, CommandResult, ErrorCode, Event, PROTOCOL_VERSION, Pattern,
Seal, ServerMessage, VEIN_COUNT,
Actor, AgentMessage, AgentRole, ClientMessage, Command, CommandResult, ErrorCode, Event,
PROTOCOL_VERSION, Pattern, Seal, ServerMessage, State, VEIN_COUNT,
};
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
@@ -48,6 +50,8 @@ enum Outbound {
struct Shared {
session: Arc<Mutex<Session>>,
store: Arc<std::sync::Mutex<ProjectStore>>,
engine: Arc<dyn Engine>,
agent: Arc<Mutex<agent::Agent>>,
out: broadcast::Sender<Outbound>,
token: Arc<String>,
session_id: Arc<String>,
@@ -106,11 +110,20 @@ pub async fn run(config: HostConfig) -> anyhow::Result<()> {
};
let session = Arc::new(Mutex::new(session));
let store = Arc::new(std::sync::Mutex::new(store));
let agent_config = agent::AgentConfig::from_env();
let agent_mode = if agent_config.is_mock() {
"mock"
} else {
"live"
};
let agent = Arc::new(Mutex::new(agent::Agent::new(agent_config)));
let (out, _) = broadcast::channel::<Outbound>(128);
let session_id = format!("zhila-{:x}", now_ms());
let shared = Shared {
session,
store,
engine: control.clone(),
agent,
out: out.clone(),
token: Arc::new(token.clone()),
session_id: Arc::new(session_id.clone()),
@@ -139,6 +152,7 @@ pub async fn run(config: HostConfig) -> anyhow::Result<()> {
&session_id,
&project_name,
&project_path,
agent_mode,
);
if token == "dev" {
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
@@ -173,11 +187,13 @@ fn print_banner(
session_id: &str,
project_name: &str,
project_path: &std::path::Path,
agent_mode: &str,
) {
println!();
println!(" ZHILA engine");
println!(" ws://127.0.0.1:{port} · token {token}");
println!(" project {project_name} · {}", project_path.display());
println!(" choir {agent_mode}");
if info.running {
let buffer = if info.buffer_frames == 0 {
"default".to_string()
@@ -249,6 +265,211 @@ fn sanitize_name(name: &str) -> String {
}
}
async fn handle_agent(
shared: &Shared,
direct_tx: &mpsc::UnboundedSender<Outbound>,
id: u64,
command: Command,
) {
match command {
Command::AgentAsk { text } => {
let (context, revision) = {
let session = shared.session.lock().await;
let state = session.snapshot();
(
agent::describe(&state, &shared.engine.meter()),
state.revision,
)
};
broadcast_event(
shared,
revision,
Event::AgentMessage {
message: AgentMessage {
role: AgentRole::User,
text: text.clone(),
},
},
);
let turn = {
let mut agent = shared.agent.lock().await;
agent.ask(&text, &context).await
};
for immediate in &turn.immediate {
apply_agent_immediate(shared, immediate).await;
}
let revision = shared.session.lock().await.revision();
broadcast_event(
shared,
revision,
Event::AgentMessage {
message: AgentMessage {
role: AgentRole::Agent,
text: turn.reply.clone(),
},
},
);
if let Some(proposal) = turn.proposal {
broadcast_event(
shared,
revision,
Event::AgentProposal {
id: proposal.id,
description: proposal.description,
commands: proposal.commands,
},
);
}
send_agent_result(shared, direct_tx, id).await;
}
Command::AgentApprove { id: proposal_id } => {
let proposal = {
let mut agent = shared.agent.lock().await;
agent.take(proposal_id)
};
if let Some(proposal) = proposal {
for command in &proposal.commands {
let outcome = {
let mut session = shared.session.lock().await;
session.apply(command.clone(), Actor::Agent)
};
if let Ok((result, events)) = outcome {
let revision = match result {
CommandResult::State { state } | CommandResult::Snapshot { state } => {
state.revision
}
};
for event in events {
broadcast_event(shared, revision, event);
}
}
}
save_now(shared).await;
let note = "Applied. The room has moved.";
{
let mut agent = shared.agent.lock().await;
agent.note(note);
}
let revision = shared.session.lock().await.revision();
broadcast_event(
shared,
revision,
Event::AgentMessage {
message: AgentMessage {
role: AgentRole::Agent,
text: note.into(),
},
},
);
broadcast_event(
shared,
revision,
Event::AgentResolved {
id: proposal_id,
applied: true,
},
);
}
send_agent_result(shared, direct_tx, id).await;
}
Command::AgentReject { id: proposal_id } => {
let note = "Rejected. I will leave the room as it is.";
{
let mut agent = shared.agent.lock().await;
agent.take(proposal_id);
agent.note(note);
}
let revision = shared.session.lock().await.revision();
broadcast_event(
shared,
revision,
Event::AgentMessage {
message: AgentMessage {
role: AgentRole::Agent,
text: note.into(),
},
},
);
broadcast_event(
shared,
revision,
Event::AgentResolved {
id: proposal_id,
applied: false,
},
);
send_agent_result(shared, direct_tx, id).await;
}
Command::AgentReset => {
{
let mut agent = shared.agent.lock().await;
agent.reset();
}
send_agent_result(shared, direct_tx, id).await;
}
_ => {}
}
}
async fn apply_agent_immediate(shared: &Shared, command: &Command) {
if matches!(command, Command::Render) {
let input = render_input(shared).await;
let root = shared
.store
.lock()
.expect("project store lock poisoned")
.root()
.to_path_buf();
let rendered = tokio::task::spawn_blocking(move || render_to_wav(input, &root)).await;
if let Ok(Ok((path, duration_sec))) = rendered {
let revision = shared.session.lock().await.revision();
broadcast_event(shared, revision, Event::Rendered { path, duration_sec });
}
return;
}
let outcome = {
let mut session = shared.session.lock().await;
session.apply(command.clone(), Actor::Agent)
};
if let Ok((result, events)) = outcome {
let revision = match result {
CommandResult::State { state } | CommandResult::Snapshot { state } => state.revision,
};
for event in events {
broadcast_event(shared, revision, event);
}
}
}
async fn render_input(shared: &Shared) -> RenderInput {
let session = shared.session.lock().await;
RenderInput {
sections: session.sections().to_vec(),
material: session.material_snapshot(),
gains: session.gains(),
bpm: session.bpm(),
project: session.project_name().to_string(),
}
}
fn broadcast_event(shared: &Shared, revision: u64, event: Event) {
let message = ServerMessage::Event { revision, event };
let _ = shared.out.send(Outbound::Text(
serde_json::to_string(&message).expect("event serializes"),
));
}
async fn send_agent_result(shared: &Shared, direct_tx: &mpsc::UnboundedSender<Outbound>, id: u64) {
let state: State = shared.session.lock().await.snapshot();
let message = ServerMessage::Result {
id,
result: CommandResult::State { state },
};
let _ = direct_tx.send(Outbound::Text(
serde_json::to_string(&message).expect("agent result serializes"),
));
}
async fn save_now(shared: &Shared) {
let session = shared.session.lock().await;
let state = session.snapshot();
@@ -392,6 +613,17 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
),
)
.await?;
let agent_messages = shared.agent.lock().await.messages().to_vec();
send_out(
&mut sink,
Outbound::Text(
serde_json::to_string(&ServerMessage::AgentHistory {
messages: agent_messages,
})
.expect("agent history serializes"),
),
)
.await?;
let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::<Outbound>();
let mut events = shared.out.subscribe();
@@ -588,6 +820,16 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
}
continue;
}
if matches!(
&command,
Command::AgentAsk { .. }
| Command::AgentApprove { .. }
| Command::AgentReject { .. }
| Command::AgentReset
) {
handle_agent(&shared, &direct_tx, id, command).await;
continue;
}
let should_save = !matches!(
&command,
Command::Play