//! Discussion endpoint for LLM-powered contract creation. //! //! This handler provides an ephemeral conversation with Makima to help users //! define and create contracts through natural dialogue. use axum::{ extract::State, http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::ToSchema; use uuid::Uuid; use crate::db::{models::CreateContractRequest, repository}; use crate::llm::{ claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, groq::{GroqClient, GroqError, Message, ToolCallResponse}, discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, LlmModel, ToolCall, ToolResult, UserQuestion, }; use crate::server::auth::Authenticated; use crate::server::state::SharedState; /// Maximum number of tool-calling rounds to prevent infinite loops const MAX_TOOL_ROUNDS: usize = 10; /// System prompt for Makima character in contract discussions const DISCUSS_SYSTEM_PROMPT: &str = r#" You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. ## Your Personality - Professional yet personable - Focused on understanding the user's actual needs - Ask clarifying questions when requirements are vague - Guide the conversation toward actionable outcomes - Comfortable making recommendations based on experience ## Your Goal Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: - A clear name and description - The right contract type (simple, specification, or execute) - Understanding of the scope and requirements ## Contract Types - **simple**: Quick tasks with minimal planning (plan -> execute phases only) - **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) - **execute**: Direct implementation when requirements are already clear (execute phase only) ## Guidelines 1. **Start by understanding**: Ask about what they want to build 2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? 3. **Gather requirements**: What are the must-haves vs nice-to-haves? 4. **Identify context**: Is there existing code? Which repository? 5. **Recommend type**: Suggest the appropriate contract type 6. **Confirm and create**: When the user is satisfied, create the contract ## When to Create the Contract Create the contract when: - You have a clear understanding of what the user wants - The user has confirmed they're ready to proceed - You've gathered enough information for a meaningful contract Do NOT create the contract if: - The user is still exploring ideas - Key information is missing - The user hasn't indicated readiness {transcript_context} "#; /// Chat message in history #[derive(Debug, Clone, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChatMessage { /// Role: "user" or "assistant" pub role: String, /// Message content pub content: String, } /// Request to discuss a potential contract #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DiscussContractRequest { /// The user's message pub message: String, /// Optional model selection (default: claude-sonnet) #[serde(default)] pub model: Option, /// Conversation history for context continuity #[serde(default)] pub history: Option>, /// Optional transcript context from current session #[serde(default)] pub transcript_context: Option, } /// Response from the discussion endpoint #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DiscussContractResponse { /// Makima's response message pub response: String, /// Tool calls that were executed (e.g., create_contract) pub tool_calls: Vec, /// If a contract was created, its details #[serde(skip_serializing_if = "Option::is_none")] pub created_contract: Option, /// Pending questions (if LLM needs clarification) #[serde(skip_serializing_if = "Option::is_none")] pub pending_questions: Option>, } /// Information about a tool call that was executed #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ToolCallInfo { pub name: String, pub result: ToolResult, } /// Information about a created contract #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreatedContractInfo { pub id: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub contract_type: String, pub initial_phase: String, } /// Enum to hold LLM clients enum LlmClient { Groq(GroqClient), Claude(ClaudeClient), } /// Unified result from LLM call struct LlmResult { content: Option, tool_calls: Vec, raw_tool_calls: Vec, finish_reason: String, } /// Discuss a potential contract with Makima #[utoipa::path( post, path = "/api/v1/contracts/discuss", request_body = DiscussContractRequest, responses( (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn discuss_contract_handler( State(state): State, Authenticated(auth): Authenticated, Json(request): Json, ) -> impl IntoResponse { // Check if database is configured let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "Database not configured" })), ) .into_response(); }; // Parse model selection (default to Claude Sonnet) let model = request .model .as_ref() .and_then(|m| LlmModel::from_str(m)) .unwrap_or(LlmModel::ClaudeSonnet); tracing::info!("Contract discussion using LLM model: {:?}", model); // Initialize the appropriate LLM client let llm_client = match model { LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { Ok(client) => LlmClient::Claude(client), Err(ClaudeError::MissingApiKey) => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Claude client error: {}", e) })), ) .into_response(); } }, LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { Ok(client) => LlmClient::Claude(client), Err(ClaudeError::MissingApiKey) => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Claude client error: {}", e) })), ) .into_response(); } }, LlmModel::GroqKimi => match GroqClient::from_env() { Ok(client) => LlmClient::Groq(client), Err(GroqError::MissingApiKey) => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "GROQ_API_KEY not configured" })), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Groq client error: {}", e) })), ) .into_response(); } }, }; // Build system prompt with optional transcript context let transcript_section = match &request.transcript_context { Some(ctx) => format!( "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", ctx ), None => String::new(), }; let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); // Run the discussion agentic loop run_discuss_agentic_loop( pool, &llm_client, system_prompt, &request, auth.owner_id, ) .await } /// Run the agentic loop for contract discussion async fn run_discuss_agentic_loop( pool: &sqlx::PgPool, llm_client: &LlmClient, system_prompt: String, request: &DiscussContractRequest, owner_id: Uuid, ) -> axum::response::Response { // Build initial messages let mut messages = vec![Message { role: "system".to_string(), content: Some(system_prompt), tool_calls: None, tool_call_id: None, }]; // Add conversation history if provided if let Some(history) = &request.history { for msg in history { messages.push(Message { role: msg.role.clone(), content: Some(msg.content.clone()), tool_calls: None, tool_call_id: None, }); } } // Add current user message messages.push(Message { role: "user".to_string(), content: Some(request.message.clone()), tool_calls: None, tool_call_id: None, }); // State for tracking let mut all_tool_call_infos: Vec = Vec::new(); let mut final_response: Option = None; let mut created_contract: Option = None; let mut pending_questions: Option> = None; // Multi-turn agentic tool calling loop for round in 0..MAX_TOOL_ROUNDS { tracing::info!( round = round, total_tool_calls = all_tool_call_infos.len(), "Contract discussion loop iteration" ); // Call the appropriate LLM API let result = match llm_client { LlmClient::Groq(groq) => { match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await { Ok(r) => LlmResult { content: r.content, tool_calls: r.tool_calls, raw_tool_calls: r.raw_tool_calls, finish_reason: r.finish_reason, }, Err(e) => { tracing::error!("Groq API error: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("LLM API error: {}", e) })), ) .into_response(); } } } LlmClient::Claude(claude_client) => { let claude_messages = claude::groq_messages_to_claude(&messages); match claude_client .chat_with_tools(claude_messages, &DISCUSS_TOOLS) .await { Ok(r) => { let raw_tool_calls: Vec = r .tool_calls .iter() .map(|tc| ToolCallResponse { id: tc.id.clone(), call_type: "function".to_string(), function: crate::llm::groq::FunctionCall { name: tc.name.clone(), arguments: tc.arguments.to_string(), }, }) .collect(); LlmResult { content: r.content, tool_calls: r.tool_calls, raw_tool_calls, finish_reason: r.stop_reason, } } Err(e) => { tracing::error!("Claude API error: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("LLM API error: {}", e) })), ) .into_response(); } } } }; // Check if there are tool calls to execute if result.tool_calls.is_empty() { final_response = result.content; break; } // Add assistant message with tool calls to conversation messages.push(Message { role: "assistant".to_string(), content: result.content.clone(), tool_calls: Some(result.raw_tool_calls.clone()), tool_call_id: None, }); // Execute each tool call for (i, tool_call) in result.tool_calls.iter().enumerate() { tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call"); // Parse the tool call let mut execution_result = parse_discuss_tool_call(tool_call); // Handle async discussion tool requests if let Some(discuss_request) = execution_result.request.take() { let async_result = handle_discuss_request(pool, discuss_request, owner_id).await; execution_result.success = async_result.success; execution_result.message = async_result.message; execution_result.data = async_result.data; // Check if a contract was created if let Some(ref data) = execution_result.data { if let Some(contract_info) = data.get("createdContract") { created_contract = Some(CreatedContractInfo { id: contract_info["id"].as_str().unwrap_or("").to_string(), name: contract_info["name"].as_str().unwrap_or("").to_string(), description: contract_info["description"].as_str().map(|s| s.to_string()), contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), }); } } } // Check for pending user questions if let Some(questions) = execution_result.pending_questions { tracing::info!( question_count = questions.len(), "Discussion LLM requesting user input" ); pending_questions = Some(questions); all_tool_call_infos.push(ToolCallInfo { name: tool_call.name.clone(), result: ToolResult { success: execution_result.success, message: execution_result.message.clone(), }, }); break; } // Build tool result message let result_content = if let Some(data) = &execution_result.data { json!({ "success": execution_result.success, "message": execution_result.message, "data": data }) .to_string() } else { json!({ "success": execution_result.success, "message": execution_result.message }) .to_string() }; // Add tool result message let tool_call_id = match llm_client { LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), LlmClient::Claude(_) => tool_call.id.clone(), }; messages.push(Message { role: "tool".to_string(), content: Some(result_content), tool_calls: None, tool_call_id: Some(tool_call_id), }); // Track for response all_tool_call_infos.push(ToolCallInfo { name: tool_call.name.clone(), result: ToolResult { success: execution_result.success, message: execution_result.message, }, }); } // If user questions are pending, pause if pending_questions.is_some() { final_response = result.content; break; } // If finish reason indicates completion, exit loop let finish_lower = result.finish_reason.to_lowercase(); if finish_lower == "stop" || finish_lower == "end_turn" { final_response = result.content; break; } } // Build response let response_text = final_response.unwrap_or_else(|| { if all_tool_call_infos.is_empty() { "I couldn't understand your request. Please try rephrasing.".to_string() } else { "Done!".to_string() } }); ( StatusCode::OK, Json(DiscussContractResponse { response: response_text, tool_calls: all_tool_call_infos, created_contract, pending_questions, }), ) .into_response() } /// Result from handling an async discussion tool request struct DiscussRequestResult { success: bool, message: String, data: Option, } /// Handle async discussion tool requests that require database access async fn handle_discuss_request( pool: &sqlx::PgPool, request: DiscussToolRequest, owner_id: Uuid, ) -> DiscussRequestResult { match request { DiscussToolRequest::CreateContract { name, description, contract_type, repository_url, local_only, } => { // Create the contract request let create_req = CreateContractRequest { name: name.clone(), description: Some(description.clone()), contract_type: Some(contract_type.clone()), template_id: None, initial_phase: None, autonomous_loop: None, phase_guard: None, local_only: Some(local_only), auto_merge_local: None, }; match repository::create_contract_for_owner(pool, owner_id, create_req).await { Ok(contract) => { // If repository URL was provided, try to add it if let Some(repo_url) = repository_url { // Try to add as remote repository let add_result = repository::add_remote_repository( pool, contract.id, &format!("{} Repository", name), &repo_url, true, // is_primary ) .await; if let Err(e) = add_result { tracing::warn!( "Failed to add repository to contract {}: {}", contract.id, e ); } } DiscussRequestResult { success: true, message: format!("Contract '{}' created successfully!", contract.name), data: Some(json!({ "createdContract": { "id": contract.id.to_string(), "name": contract.name, "description": contract.description, "contractType": contract.contract_type, "initialPhase": contract.phase, } })), } } Err(e) => { tracing::error!("Failed to create contract: {}", e); DiscussRequestResult { success: false, message: format!("Failed to create contract: {}", e), data: None, } } } } } }