P2c: in-app choir agent with proposals and approval
This commit is contained in:
@@ -587,7 +587,11 @@ impl Session {
|
||||
Command::SelectSection { index } => self.select_section(index, actor),
|
||||
Command::ClearSection { index } => self.clear_section(index, actor),
|
||||
Command::CopySection { from, to } => self.copy_section(from, to, actor),
|
||||
Command::Render => Ok((
|
||||
Command::Render
|
||||
| Command::AgentAsk { .. }
|
||||
| Command::AgentApprove { .. }
|
||||
| Command::AgentReject { .. }
|
||||
| Command::AgentReset => Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,7 @@ impl VeinId {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Actor {
|
||||
Hand,
|
||||
Agent,
|
||||
Api,
|
||||
Mcp,
|
||||
System,
|
||||
@@ -165,6 +166,9 @@ pub enum ServerMessage {
|
||||
id: u64,
|
||||
core: CoreSummary,
|
||||
},
|
||||
AgentHistory {
|
||||
messages: Vec<AgentMessage>,
|
||||
},
|
||||
Error {
|
||||
id: Option<u64>,
|
||||
code: ErrorCode,
|
||||
@@ -219,6 +223,16 @@ pub enum Command {
|
||||
to: u8,
|
||||
},
|
||||
Render,
|
||||
AgentAsk {
|
||||
text: String,
|
||||
},
|
||||
AgentApprove {
|
||||
id: u64,
|
||||
},
|
||||
AgentReject {
|
||||
id: u64,
|
||||
},
|
||||
AgentReset,
|
||||
Undo,
|
||||
Redo,
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
@@ -282,6 +296,32 @@ pub enum Event {
|
||||
path: String,
|
||||
duration_sec: f32,
|
||||
},
|
||||
AgentMessage {
|
||||
message: AgentMessage,
|
||||
},
|
||||
AgentProposal {
|
||||
id: u64,
|
||||
description: String,
|
||||
commands: Vec<Command>,
|
||||
},
|
||||
AgentResolved {
|
||||
id: u64,
|
||||
applied: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentRole {
|
||||
User,
|
||||
Agent,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentMessage {
|
||||
pub role: AgentRole,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user