makepad/apps/route/src/claude_agent.rs
Admin c460905e2a maps + route: the self-baking Amsterdam test map, reversible mkmap weave, the rtsmap generator, road faces, guarded first-run
Squashed from work:
- rtsmap: one seeded generator for tiled strategy maps
- map: bake a runnable Amsterdam test map from inside the route app
- route: the first-run test map starts itself, and its buttons are guarded
- map: the test map bakes its road faces too, from the same shared pass
- map_tiles: mkmap-extract — the weave is reversible, world-cells reconstructible from world.mkmap
- route: the local dispatcher rides the hub — its copied engine is deleted, limits preserved (aicore P1)
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
2026-09-01 16:46:33 +02:00

229 lines
8.3 KiB
Rust

//! Route's Claude escalation/dispatcher agent over the hub's Messages-API
//! provider — the Cx-driven `makepad_ai::ClaudeBackend`'s replacement.
//!
//! The provider (`makepad_ai_hub::providers::claude_api`) runs each turn on
//! its own thread and queues events; this adapter is stateful where the
//! provider is per-turn: it owns the conversation history, injects it into
//! every `TurnInput`, and maps provider events onto the seam's
//! [`AgentEvent`]s. Route's frame loop pumps `handle_event` continuously
//! (it is a map renderer), so poll-driven streaming reads as streaming.
use makepad_converse::agent_seam::{
Agent, AgentEvent, Message, MessageRole, PromptId, SessionConfig, SessionId,
StopReason, ToolDefinition,
};
use makepad_ai_hub::chat_wire::{ChatMessage, ChatRole};
use makepad_ai_hub::providers::claude_api::{ClaudeApiChatProvider, ClaudeApiConfig, ClaudeAuth};
use makepad_ai_hub::providers::provider::{ChatProvider, ProviderEvent, TurnInput};
use makepad_strict_json as json;
use makepad_widgets::*;
/// Anthropic tool schema array from the seam's tool definitions:
/// `[{name, description, input_schema}, …]`, parameters passed through as
/// the already-JSON schema each tool declares.
fn native_tools_payload(tools: &[ToolDefinition]) -> Option<json::Value> {
if tools.is_empty() {
return None;
}
let mut arr = Vec::new();
for tool in tools {
let schema = json::parse(tool.parameters.as_bytes())
.unwrap_or(json::Value::Obj(Vec::new()));
arr.push(json::Value::Obj(vec![
("name".to_string(), json::Value::Str(tool.name.clone())),
(
"description".to_string(),
json::Value::Str(tool.description.clone()),
),
("input_schema".to_string(), schema),
]));
}
Some(json::Value::Arr(arr))
}
pub struct ClaudeAgent {
model: String,
api_key: String,
provider: Option<ClaudeApiChatProvider<
makepad_ai_hub::providers::claude_api::BlockingClaudeTransport,
>>,
session_id: Option<SessionId>,
system_prompt: String,
history: Vec<ChatMessage>,
current_prompt: Option<PromptId>,
/// Streamed text of the running turn, so history gets the full reply.
turn_text: String,
/// Events minted on the UI side (session ready, submit errors) that the
/// next handle_event delivers alongside the provider's.
queued: Vec<AgentEvent>,
}
impl ClaudeAgent {
pub fn new(model: String, api_key: String) -> Self {
Self {
model,
api_key,
provider: None,
session_id: None,
system_prompt: String::new(),
history: Vec::new(),
current_prompt: None,
turn_text: String::new(),
queued: Vec::new(),
}
}
}
impl Agent for ClaudeAgent {
fn create_session(&mut self, _cx: &mut Cx, config: SessionConfig) -> SessionId {
let session_id = SessionId::new();
self.system_prompt = config.system_prompt.unwrap_or_default();
// Route holds the key already (read_secret at init); build the auth
// directly rather than re-reading env. ClaudeCli is the nearest kind
// label — kind is presentational on this wire.
let api = match ClaudeAuth::api_key(self.api_key.clone()) {
Ok(auth) => ClaudeApiConfig::new(
makepad_ai_hub::chat_wire::ProviderKind::ClaudeCli,
auth,
self.model.clone(),
),
Err(reason) => {
self.queued.push(AgentEvent::SessionError {
session_id,
error: reason,
});
self.session_id = Some(session_id);
return session_id;
}
};
let tools = native_tools_payload(&config.tools);
self.provider = Some(ClaudeApiChatProvider::new(api, tools));
self.session_id = Some(session_id);
self.queued.push(AgentEvent::SessionReady { session_id });
session_id
}
fn send_prompt(&mut self, _cx: &mut Cx, _session_id: SessionId, text: &str) -> PromptId {
let prompt_id = PromptId::new();
self.history.push(ChatMessage::new(ChatRole::User, text));
self.turn_text.clear();
let input = TurnInput::new(self.system_prompt.clone(), self.history.clone());
if let Some(provider) = &mut self.provider {
if let Err(error) = provider.begin_turn(&input) {
self.queued.push(AgentEvent::PromptError { prompt_id, error });
return prompt_id;
}
} else {
self.queued.push(AgentEvent::PromptError {
prompt_id,
error: "no session".to_string(),
});
return prompt_id;
}
self.current_prompt = Some(prompt_id);
prompt_id
}
fn send_tool_result(
&mut self,
_cx: &mut Cx,
_session_id: SessionId,
tool_use_id: &str,
result: &str,
is_error: bool,
) {
let Some(provider) = &mut self.provider else {
return;
};
let output = if is_error {
format!("ERROR: {result}")
} else {
result.to_string()
};
if let Err(error) = provider.continue_function(tool_use_id, &output) {
if let Some(prompt_id) = self.current_prompt {
self.queued.push(AgentEvent::PromptError { prompt_id, error });
}
}
}
fn cancel_prompt(&mut self, _cx: &mut Cx, _prompt_id: PromptId) {
if let Some(provider) = &mut self.provider {
provider.cancel();
}
self.current_prompt = None;
}
fn handle_event(&mut self, _cx: &mut Cx, _event: &Event) -> Vec<AgentEvent> {
let mut out = std::mem::take(&mut self.queued);
let Some(provider) = &mut self.provider else {
return out;
};
let Some(prompt_id) = self.current_prompt else {
// No turn in flight: drain silently so a late Done never leaks
// into the next prompt.
let _ = provider.poll();
return out;
};
for event in provider.poll() {
match event {
ProviderEvent::Delta(text) => {
self.turn_text.push_str(&text);
out.push(AgentEvent::TextDelta { prompt_id, text });
}
ProviderEvent::Status { .. } | ProviderEvent::Serving(_) => {}
ProviderEvent::FunctionCall { call_id, name, arguments } => {
out.push(AgentEvent::ToolRequest {
prompt_id,
tool_use_id: call_id,
tool_name: name,
tool_input: arguments,
});
}
ProviderEvent::Done { text } => {
let full = if text.trim().is_empty() {
self.turn_text.clone()
} else {
text
};
if !full.trim().is_empty() {
self.history
.push(ChatMessage::new(ChatRole::Assistant, full));
}
self.current_prompt = None;
out.push(AgentEvent::TurnComplete {
prompt_id,
stop_reason: StopReason::EndTurn,
});
}
ProviderEvent::Error(error) => {
self.current_prompt = None;
out.push(AgentEvent::PromptError { prompt_id, error });
}
}
}
out
}
fn is_session_ready(&self, session_id: SessionId) -> bool {
self.session_id == Some(session_id)
}
fn is_stateless(&self) -> bool {
true
}
fn inject_history(&mut self, _session_id: SessionId, messages: Vec<Message>) {
self.history = messages
.iter()
.map(|message| {
let role = match message.role {
MessageRole::Assistant => ChatRole::Assistant,
_ => ChatRole::User,
};
ChatMessage::new(role, message.text())
})
.filter(|m| !m.text.trim().is_empty())
.collect();
}
}