summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-03 23:48:41 +0000
committerGitHub <noreply@github.com>2026-02-03 23:48:41 +0000
commit9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (patch)
tree53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/src/server
parentdcbf8c834626870a43b633b099f409d69d4f9b87 (diff)
downloadsoryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.tar.gz
soryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.zip
Add 'Discuss Contract' feature to listen page (#57)
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/contract_discuss.rs592
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs3
-rw-r--r--makima/src/server/openapi.rs10
4 files changed, 604 insertions, 2 deletions
diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs
new file mode 100644
index 0000000..1f98f53
--- /dev/null
+++ b/makima/src/server/handlers/contract_discuss.rs
@@ -0,0 +1,592 @@
+//! 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,
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 3e01a3e..5e172bc 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -5,6 +5,7 @@ pub mod chains;
pub mod chat;
pub mod contract_chat;
pub mod contract_daemon;
+pub mod contract_discuss;
pub mod contracts;
pub mod file_ws;
pub mod files;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index d110c18..553797f 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -151,6 +151,7 @@ pub fn make_router(state: SharedState) -> Router {
.route("/users/me/password", axum::routing::put(users::change_password_handler))
.route("/users/me/email", axum::routing::put(users::change_email_handler))
// Contract endpoints
+ .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler))
.route(
"/contracts",
get(contracts::list_contracts).post(contracts::create_contract),
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index f8c5474..a70342b 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -20,7 +20,7 @@ use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users};
+use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -97,6 +97,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
contract_chat::contract_chat_handler,
contract_chat::get_contract_chat_history,
contract_chat::clear_contract_chat_history,
+ // Contract discuss endpoint
+ contract_discuss::discuss_contract_handler,
// Repository history/settings endpoints
repository_history::list_repository_history,
repository_history::get_repository_suggestions,
@@ -137,6 +139,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
// Contract chat schemas
ContractChatMessageRecord,
ContractChatHistoryResponse,
+ // Contract discuss schemas
+ contract_discuss::ChatMessage,
+ contract_discuss::DiscussContractRequest,
+ contract_discuss::DiscussContractResponse,
+ contract_discuss::ToolCallInfo,
+ contract_discuss::CreatedContractInfo,
// Merge schemas
BranchInfo,
BranchListResponse,