//! 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<String>,
/// Conversation history for context continuity
#[serde(default)]
pub history: Option<Vec<ChatMessage>>,
/// Optional transcript context from current session
#[serde(default)]
pub transcript_context: Option<String>,
}
/// 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<ToolCallInfo>,
/// If a contract was created, its details
#[serde(skip_serializing_if = "Option::is_none")]
pub created_contract: Option<CreatedContractInfo>,
/// Pending questions (if LLM needs clarification)
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_questions: Option<Vec<UserQuestion>>,
}
/// 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<String>,
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<String>,
tool_calls: Vec<ToolCall>,
raw_tool_calls: Vec<ToolCallResponse>,
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<SharedState>,
Authenticated(auth): Authenticated,
Json(request): Json<DiscussContractRequest>,
) -> 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<ToolCallInfo> = Vec::new();
let mut final_response: Option<String> = None;
let mut created_contract: Option<CreatedContractInfo> = None;
let mut pending_questions: Option<Vec<UserQuestion>> = 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<ToolCallResponse> = 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<serde_json::Value>,
}
/// 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,
}
}
}
}
}
}