diff --git a/apps/code/docs/AGENT_NATIVE_PROTOCOL.md b/apps/code/docs/AGENT_NATIVE_PROTOCOL.md new file mode 100644 index 0000000..4d74030 --- /dev/null +++ b/apps/code/docs/AGENT_NATIVE_PROTOCOL.md @@ -0,0 +1,100 @@ +# Codra Agent-Native Protocol Adoption + +How Codra will adopt the Talocode Agent-Native Protocol for safe, audited agent operations. + +## Current State + +Codra Code is a CLI-based AI coding agent. It currently operates through: + +- Thread-based conversations +- File read/write operations +- Terminal command execution +- Git operations +- Model relay to external providers +- Plan-based task execution + +## Planned Codra Actions + +### Read-Only Actions (No Approval Required) + +| Action | Risk | Description | +|--------|------|-------------| +| `codra.plan.create` | read | Create an execution plan | +| `codra.thread.resume` | read | Resume a conversation thread | +| `codra.context.compress` | read | Compress context for token efficiency | +| `codra.file.read` | read | Read file contents | +| `codra.git.status` | read | Check git repository status | +| `codra.git.diff` | read | View file differences | + +### Write Actions (Approval Required) + +| Action | Risk | Description | +|--------|------|-------------| +| `codra.plan.run` | medium | Execute an approved plan | +| `codra.file.write` | medium | Write or modify file contents | +| `codra.git.commit` | medium | Create a git commit | +| `codra.command.run` | high | Execute terminal commands | + +### Destructive Actions (Unsupported) + +- `codra.git.force_push` — not supported +- `codra.file.delete` — not supported +- `codra.database.drop` — not supported + +## Context Providers + +| Provider | Privacy | Description | +|----------|---------|-------------| +| `codra.thread` | workspace | Current conversation thread | +| `codra.plan` | workspace | Current execution plan | +| `codra.files` | workspace | File tree and contents | +| `codra.git` | workspace | Repository state | +| `codra.model` | private | Model relay configuration | + +## Permission Gates + +Codra actions declare required permissions: + +- `codra:read` — read files, git status, context +- `codra:write` — modify files, create commits +- `codra:execute` — run terminal commands + +Write and execute actions require approval before execution. + +## Audit Trail + +Every Codra action creates an audit event with: + +- Action type and description +- Input parameters (sanitized) +- Execution result +- Timestamp +- Actor identifier + +No raw secrets, API keys, or file contents exceeding safe limits are logged. + +## Adoption Status + +| Component | Status | +|-----------|--------| +| Action registry | Planned | +| Context providers | Planned | +| Permission gates | Planned | +| Audit logging | Partial (existing run logging) | +| Approval workflows | Existing approval system | +| Protocol endpoint | Not yet implemented | + +## Migration Plan + +1. Map existing Codra actions to protocol format +2. Add protocol endpoint to Codra API +3. Integrate permission gates into file/git operations +4. Enhance audit logging with protocol metadata +5. Add context providers for thread/plan/file state + +## Limitations + +- Codra CLI currently operates locally — protocol is for when Codra becomes multi-user +- File operations are the primary write actions +- Terminal commands are the highest-risk actions +- No hosted execution yet diff --git a/apps/code/src/auth/index.ts b/apps/code/src/auth/index.ts index d141e6a..b935275 100644 --- a/apps/code/src/auth/index.ts +++ b/apps/code/src/auth/index.ts @@ -3,6 +3,45 @@ import * as path from 'path'; import * as os from 'os'; import chalk from 'chalk'; +const MAX_DEBUG_BODY_LENGTH = 200; + +async function readJsonResponse(response: Response, context: string): Promise { + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.includes('application/json')) { + const rawBody = await response.text(); + const isHtml = rawBody.trimStart().startsWith('<') || rawBody.includes(' { export async function startLogin(options: { noBrowser?: boolean; authUrl?: string } = {}): Promise { const authBaseUrl = options.authUrl || getAuthBaseUrl(); - + const startEndpoint = `${authBaseUrl}/api/codra/auth/device/start`; + console.log(chalk.cyan('\n Codra Code Authentication')); console.log(chalk.gray(' Starting Tera login flow...\n')); try { // Start device auth session - const startResponse = await fetch(`${authBaseUrl}/api/codra/auth/device/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - cli_version: '0.1.6', - platform: process.platform - }) - }); + let startResponse: Response; + try { + startResponse = await fetch(startEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cli_version: '0.1.6', + platform: process.platform + }) + }); + } catch (fetchErr) { + const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); + throw new LoginError( + `Network error connecting to Tera: ${msg}`, + 'NETWORK_ERROR', + undefined, + '/api/codra/auth/device/start' + ); + } if (!startResponse.ok) { - const error = await startResponse.text(); - throw new Error(`Failed to start auth session: ${error}`); + try { + await readJsonResponse(startResponse, 'Tera auth start endpoint'); + } catch (parseErr) { + if (parseErr instanceof Error && parseErr.message.startsWith('NON_JSON_RESPONSE')) { + throw new LoginError( + `Tera returned a web page instead of an API response (HTTP ${startResponse.status}). ` + + `The login endpoint may not be deployed yet. Run "codra-code doctor" to check.`, + 'NON_JSON_RESPONSE', + startResponse.status, + '/api/codra/auth/device/start' + ); + } + throw new LoginError( + `Auth session start failed (HTTP ${startResponse.status})`, + 'HTTP_ERROR', + startResponse.status, + '/api/codra/auth/device/start' + ); + } } - const startData = await startResponse.json(); + const startData = await readJsonResponse(startResponse, 'Tera auth start endpoint'); const { device_code, user_code, verification_url, expires_at, interval } = startData; console.log(chalk.gray(' Device Code:'), chalk.white(user_code)); @@ -191,6 +259,7 @@ async function openBrowser(url: string): Promise { async function pollForAuth(deviceCode: string, authBaseUrl: string, interval: number): Promise { const maxAttempts = 150; // 5 minutes with 2-second intervals const pollInterval = interval * 1000; + let nonJsonWarningShown = false; for (let i = 0; i < maxAttempts; i++) { try { @@ -201,8 +270,18 @@ async function pollForAuth(deviceCode: string, authBaseUrl: string, interval: nu }); if (response.ok) { - const data = await response.json(); - + let data: any; + try { + data = await readJsonResponse(response, 'Tera auth poll endpoint'); + } catch { + if (!nonJsonWarningShown) { + console.log(chalk.yellow(' Warning: Tera poll endpoint returned non-JSON response.')); + nonJsonWarningShown = true; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } + if (data.status === 'approved' && data.token) { return { userId: data.user_id || data.userId, diff --git a/apps/code/src/commands/doctor.ts b/apps/code/src/commands/doctor.ts index b5c64b1..823af01 100644 --- a/apps/code/src/commands/doctor.ts +++ b/apps/code/src/commands/doctor.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import { getConfig } from '../config.js'; import { createProvider } from '../providers/index.js'; +import { getAuthBaseUrl, isAuthenticated } from '../auth/index.js'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -69,6 +70,42 @@ export async function doctorCommand() { console.log(chalk.gray(` Project config: ${fs.existsSync(mcpConfigPath) ? 'Found' : 'Not found'}`)); console.log(chalk.gray(` User config: ${fs.existsSync(userMcpConfigPath) ? 'Found' : 'Not found'}`)); + // Check Tera API connectivity + console.log(chalk.cyan('\n Tera API:')); + const teraBaseUrl = getAuthBaseUrl(); + console.log(chalk.gray(` Base URL: ${teraBaseUrl}`)); + console.log(chalk.gray(` Authenticated: ${isAuthenticated() ? 'Yes' : 'No'}`)); + + try { + const testUrl = `${teraBaseUrl}/api/codra/auth/device/start`; + const testResponse = await fetch(testUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cli_version: '0.1.6', platform: process.platform }), + signal: AbortSignal.timeout(10000) + }); + + const contentType = testResponse.headers.get('content-type') || ''; + const isJson = contentType.includes('application/json'); + + if (isJson && testResponse.ok) { + console.log(chalk.green(' Login endpoint: Reachable (JSON)')); + } else if (isJson && !testResponse.ok) { + console.log(chalk.yellow(` Login endpoint: Returns JSON but status ${testResponse.status}`)); + } else { + console.log(chalk.red(' Login endpoint: Returns HTML instead of JSON')); + console.log(chalk.red(' This usually means the API route is not deployed or is misconfigured.')); + console.log(chalk.gray(' Try: codra-code login --no-browser')); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('timeout') || msg.includes('Timeout')) { + console.log(chalk.yellow(' Login endpoint: Timed out (10s)')); + } else { + console.log(chalk.red(` Login endpoint: Unreachable (${msg})`)); + } + } + // Check plugins console.log(chalk.cyan('\n Plugins:')); const pluginsDir = path.join(process.cwd(), 'plugins'); diff --git a/apps/code/src/providers/index.ts b/apps/code/src/providers/index.ts index cf20da7..c4ae930 100644 --- a/apps/code/src/providers/index.ts +++ b/apps/code/src/providers/index.ts @@ -2,11 +2,13 @@ import type { Provider } from './types.js'; import { MockProvider } from './mock.js'; import { OpenAIProvider } from './openai.js'; import { OllamaProvider } from './ollama.js'; +import { TeraProvider } from './tera.js'; export type { Provider, Message, ProviderResponse } from './types.js'; export { MockProvider } from './mock.js'; export { OpenAIProvider } from './openai.js'; export { OllamaProvider } from './ollama.js'; +export { TeraProvider } from './tera.js'; export function createProvider(providerName: string, config?: { baseUrl?: string; apiKey?: string }): Provider { switch (providerName.toLowerCase()) { @@ -16,8 +18,10 @@ export function createProvider(providerName: string, config?: { baseUrl?: string return new OpenAIProvider(config?.baseUrl, config?.apiKey); case 'ollama': return new OllamaProvider(config?.baseUrl); + case 'tera': + return new TeraProvider(config?.baseUrl); default: - throw new Error(`Unknown provider: ${providerName}. Supported: mock, openai, ollama`); + throw new Error(`Unknown provider: ${providerName}. Supported: mock, openai, ollama, tera`); } } diff --git a/apps/code/src/providers/tera.ts b/apps/code/src/providers/tera.ts new file mode 100644 index 0000000..aa7d118 --- /dev/null +++ b/apps/code/src/providers/tera.ts @@ -0,0 +1,160 @@ +import { Provider, Message, ProviderResponse } from './types.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const AUTH_FILE = path.join(os.homedir(), '.codra', 'auth.json'); + +function getTeraToken(): string | null { + if (process.env.CODRA_API_KEY) return process.env.CODRA_API_KEY; + if (fs.existsSync(AUTH_FILE)) { + try { + const auth = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')); + return auth.accessToken || null; + } catch { + return null; + } + } + return null; +} + +export class TeraProvider implements Provider { + name = 'tera'; + private baseUrl: string; + private model: string; + + constructor(baseUrl?: string, model?: string) { + this.baseUrl = baseUrl || process.env.CODRA_BASE_URL || 'https://teraai.chat'; + this.model = model || process.env.CODRA_MODEL || 'gpt-4o-mini'; + } + + async chat(messages: Message[], model: string): Promise { + const token = getTeraToken(); + if (!token) { + throw new Error('Tera authentication required. Run: codra-code login'); + } + + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + model: model || this.model, + messages: messages.map(m => ({ role: m.role, content: m.content })), + temperature: 0.7, + max_tokens: 4096, + stream: false + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Tera API error: ${response.status} - ${error}`); + } + + const data = await response.json(); + const choice = data.choices?.[0]; + + return { + content: choice?.message?.content || 'No response generated', + model: data.model || model || this.model, + provider: 'tera', + usage: data.usage ? { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens + } : undefined + }; + } + + async chatStreaming( + messages: Message[], + model: string, + onChunk: (delta: string) => void + ): Promise { + const token = getTeraToken(); + if (!token) { + throw new Error('Tera authentication required. Run: codra-code login'); + } + + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + model: model || this.model, + messages: messages.map(m => ({ role: m.role, content: m.content })), + temperature: 0.7, + max_tokens: 4096, + stream: true + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Tera API error: ${response.status} - ${error}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const decoder = new TextDecoder(); + let fullContent = ''; + let promptTokens = 0; + let completionTokens = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) continue; + const data = trimmed.slice(6); + if (data === '[DONE]') break; + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + fullContent += delta; + onChunk(delta); + } + if (parsed.usage) { + promptTokens = parsed.usage.prompt_tokens || 0; + completionTokens = parsed.usage.completion_tokens || 0; + } + } catch { + // skip malformed chunks + } + } + } + + return { + content: fullContent, + model: model || this.model, + provider: 'tera', + usage: promptTokens > 0 ? { promptTokens, completionTokens } : undefined + }; + } + + async isAvailable(): Promise { + const token = getTeraToken(); + if (!token) return false; + + try { + const response = await fetch(`${this.baseUrl}/v1/models`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/crates/codra-core/Cargo.toml b/crates/codra-core/Cargo.toml index 468a691..aa87813 100644 --- a/crates/codra-core/Cargo.toml +++ b/crates/codra-core/Cargo.toml @@ -9,9 +9,13 @@ serde_json.workspace = true tokio.workspace = true reqwest = { workspace = true } chrono = { workspace = true } +toml = "0.8" uuid = { version = "1.7", features = ["v4", "fast-rng"] } codra-protocol = { path = "../codra-protocol" } codra-tools = { path = "../codra-tools" } +async-trait = "0.1" +regex = "1.10" +futures = "0.3" [dev-dependencies] tempfile = "3" diff --git a/crates/codra-core/src/agent_loop.rs b/crates/codra-core/src/agent_loop.rs new file mode 100644 index 0000000..7df1514 --- /dev/null +++ b/crates/codra-core/src/agent_loop.rs @@ -0,0 +1,350 @@ +use crate::compaction::{CompactionPolicy, CompactionService}; +use crate::hooks::{HookDecision, HookEvent, HookSystem}; +use crate::provider::IntelligenceProvider; +use crate::token_counter::TokenCounter; +use crate::tool_dispatcher::{ToolCall, ToolDispatcher, ToolOutput}; +use codra_protocol::{GenerationRequest, TokenUsage}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentMessage { + pub role: String, + pub content: String, + #[serde(default)] + pub tool_calls: Vec, + #[serde(default)] + pub tool_call_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + pub system_prompt: String, + pub max_turns: usize, + pub max_tokens: Option, + pub temperature: Option, + #[serde(default = "default_auto_compact")] + pub auto_compact_threshold: f64, +} + +fn default_auto_compact() -> f64 { + 0.85 +} + +#[derive(Debug, Clone)] +pub struct TurnSummary { + pub turn_number: usize, + pub messages_added: usize, + pub tool_calls_made: usize, + pub token_usage: Option, + pub compacted: bool, +} + +pub struct AgentLoop<'a> { + provider: &'a dyn IntelligenceProvider, + dispatcher: Arc>, + hook_system: HookSystem, + token_counter: TokenCounter, + compaction_service: CompactionService, + config: AgentConfig, +} + +impl<'a> AgentLoop<'a> { + pub fn new( + provider: &'a dyn IntelligenceProvider, + dispatcher: Arc>, + config: AgentConfig, + ) -> Self { + Self { + provider, + dispatcher, + hook_system: HookSystem::new(), + token_counter: TokenCounter::new(), + compaction_service: CompactionService::new(CompactionPolicy { + auto_compact_threshold: config.auto_compact_threshold, + ..Default::default() + }), + config, + } + } + + pub fn with_hooks(mut self, hooks: HookSystem) -> Self { + self.hook_system = hooks; + self + } + + pub async fn run( + &self, + user_prompt: &str, + conversation: &mut Vec, + ) -> Result { + let mut total_turns = 0; + let mut total_tool_calls = 0; + let mut accumulated_usage = TokenUsage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }; + + conversation.push(AgentMessage { + role: "user".to_string(), + content: user_prompt.to_string(), + tool_calls: vec![], + tool_call_id: None, + }); + + for turn in 0..self.config.max_turns { + total_turns = turn + 1; + + let estimated_tokens = self.token_counter.estimate_conversation(conversation); + let context_window = self.token_counter.context_window(); + + if self.compaction_service.should_compact(estimated_tokens, context_window) { + self.compaction_service + .compact(conversation, self.provider) + .await?; + } + + let tool_defs = self.dispatcher.lock().await.tool_definitions(); + + let request = self.build_request(conversation, &tool_defs); + + let response = self.provider.generate(&request).map_err(|e| { + format!( + "LLM generation failed on turn {}: {}", + turn + 1, + e + ) + })?; + + if let Some(ref usage) = response.token_usage { + accumulated_usage.prompt_tokens += usage.prompt_tokens; + accumulated_usage.completion_tokens += usage.completion_tokens; + accumulated_usage.total_tokens += usage.total_tokens; + } + + let parsed = self.parse_response(&response.content)?; + + match parsed { + ResponseParse::TextOnly(text) => { + conversation.push(AgentMessage { + role: "assistant".to_string(), + content: text.clone(), + tool_calls: vec![], + tool_call_id: None, + }); + return Ok(AgentLoopResult { + final_text: text, + turns: total_turns, + tool_calls_made: total_tool_calls, + token_usage: accumulated_usage, + messages: conversation.clone(), + }); + } + ResponseParse::WithToolCalls(text, tool_calls) => { + conversation.push(AgentMessage { + role: "assistant".to_string(), + content: text, + tool_calls: tool_calls.clone(), + tool_call_id: None, + }); + + let results = self + .execute_tool_calls(&tool_calls, conversation) + .await?; + total_tool_calls += results.len(); + + for result in results { + conversation.push(AgentMessage { + role: "tool".to_string(), + content: result.output, + tool_calls: vec![], + tool_call_id: Some(result.call_id), + }); + } + } + } + } + + Ok(AgentLoopResult { + final_text: "Max turns reached without completion.".to_string(), + turns: total_turns, + tool_calls_made: total_tool_calls, + token_usage: accumulated_usage, + messages: conversation.clone(), + }) + } + + fn build_request( + &self, + conversation: &[AgentMessage], + tool_defs: &[codra_protocol::ToolDefinition], + ) -> GenerationRequest { + let mut messages_text = String::new(); + messages_text.push_str(&self.config.system_prompt); + messages_text.push_str("\n\n"); + + for msg in conversation { + match msg.role.as_str() { + "user" => { + messages_text.push_str(&format!("User: {}\n\n", msg.content)); + } + "assistant" => { + if !msg.tool_calls.is_empty() { + let call_desc: Vec = msg + .tool_calls + .iter() + .map(|c| { + format!( + "{}({})", + c.tool_name, + serde_json::to_string(&c.arguments).unwrap_or_default() + ) + }) + .collect(); + messages_text.push_str(&format!( + "Assistant: {}\nTool calls: {}\n\n", + msg.content, + call_desc.join(", ") + )); + } else { + messages_text.push_str(&format!("Assistant: {}\n\n", msg.content)); + } + } + "tool" => { + messages_text.push_str(&format!( + "Tool result ({}): {}\n\n", + msg.tool_call_id.as_deref().unwrap_or("unknown"), + msg.content + )); + } + _ => {} + } + } + + if !tool_defs.is_empty() { + let tool_descs: Vec = tool_defs + .iter() + .map(|t| format!("- {}: {}", t.name, t.description)) + .collect(); + messages_text.push_str(&format!( + "\nAvailable tools:\n{}\n\nTo use a tool, respond with: [TOOL_CALL: tool_name(args)]\n", + tool_descs.join("\n") + )); + } + + GenerationRequest { + mode: codra_protocol::GenerationMode::PlanGeneration, + system_prompt: self.config.system_prompt.clone(), + user_prompt: messages_text, + max_tokens: self.config.max_tokens, + temperature: self.config.temperature, + } + } + + fn parse_response(&self, content: &str) -> Result { + let mut tool_calls = Vec::new(); + let mut text_parts = Vec::new(); + + for line in content.lines() { + if let Some(rest) = line.strip_prefix("[TOOL_CALL: ") { + if let Some(end) = rest.find(']') { + let call_str = &rest[..end]; + if let Some(paren_start) = call_str.find('(') { + if let Some(paren_end) = call_str.rfind(')') { + let tool_name = + call_str[..paren_start].trim().to_string(); + let args_str = &call_str[paren_start + 1..paren_end]; + let arguments: serde_json::Value = + serde_json::from_str(&format!("{{{}}}", args_str)) + .unwrap_or(serde_json::Value::Object( + serde_json::Map::new(), + )); + tool_calls.push(ToolCall { + id: format!("call_{}", uuid::Uuid::new_v4()), + tool_name, + arguments, + }); + continue; + } + } + } + } + text_parts.push(line); + } + + let text = text_parts.join("\n").trim().to_string(); + + if tool_calls.is_empty() { + Ok(ResponseParse::TextOnly(text)) + } else { + Ok(ResponseParse::WithToolCalls(text, tool_calls)) + } + } + + async fn execute_tool_calls( + &self, + tool_calls: &[ToolCall], + _conversation: &[AgentMessage], + ) -> Result, String> { + let mut results = Vec::new(); + + for call in tool_calls { + let hook_decision = self + .hook_system + .dispatch(HookEvent::PreToolUse { + tool_name: call.tool_name.clone(), + arguments: call.arguments.clone(), + }) + .await; + + if let HookDecision::Deny { reason } = hook_decision { + results.push(ToolOutput { + call_id: call.id.clone(), + success: false, + output: format!("Tool call denied by hook: {}", reason), + }); + continue; + } + + let mut dispatcher = self.dispatcher.lock().await; + let result = dispatcher.execute_tool(call).await; + + self.hook_system + .dispatch(HookEvent::PostToolUse { + tool_name: call.tool_name.clone(), + success: result.is_ok(), + }) + .await; + + match result { + Ok(output) => results.push(output), + Err(e) => { + results.push(ToolOutput { + call_id: call.id.clone(), + success: false, + output: format!("Tool execution error: {}", e), + }); + } + } + } + + Ok(results) + } +} + +enum ResponseParse { + TextOnly(String), + WithToolCalls(String, Vec), +} + +#[derive(Debug, Clone)] +pub struct AgentLoopResult { + pub final_text: String, + pub turns: usize, + pub tool_calls_made: usize, + pub token_usage: TokenUsage, + pub messages: Vec, +} diff --git a/crates/codra-core/src/compaction.rs b/crates/codra-core/src/compaction.rs new file mode 100644 index 0000000..9ab4040 --- /dev/null +++ b/crates/codra-core/src/compaction.rs @@ -0,0 +1,135 @@ +use crate::agent_loop::AgentMessage; +use crate::provider::IntelligenceProvider; +use codra_protocol::{GenerationMode, GenerationRequest}; + +#[derive(Debug, Clone)] +pub struct CompactionPolicy { + pub auto_compact_threshold: f64, + pub max_summary_tokens: usize, +} + +impl Default for CompactionPolicy { + fn default() -> Self { + Self { + auto_compact_threshold: 0.85, + max_summary_tokens: 4096, + } + } +} + +pub struct CompactionService { + policy: CompactionPolicy, +} + +impl CompactionService { + pub fn new(policy: CompactionPolicy) -> Self { + Self { policy } + } + + pub fn should_compact(&self, current_tokens: usize, context_window: usize) -> bool { + if context_window == 0 { + return false; + } + let ratio = current_tokens as f64 / context_window as f64; + ratio >= self.policy.auto_compact_threshold + } + + pub async fn compact( + &self, + conversation: &mut Vec, + provider: &dyn IntelligenceProvider, + ) -> Result<(), String> { + if conversation.is_empty() { + return Ok(()); + } + + let summary = self.generate_summary(conversation, provider).await?; + + let system_msg = conversation + .iter() + .find(|m| m.role == "system") + .cloned(); + + let recent_tail: Vec = conversation + .iter() + .rev() + .take(4) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect(); + + conversation.clear(); + + if let Some(sys) = system_msg { + conversation.push(sys); + } + + conversation.push(AgentMessage { + role: "user".to_string(), + content: format!("[Conversation Summary]\n{}\n[End Summary]", summary), + tool_calls: vec![], + tool_call_id: None, + }); + + for msg in recent_tail { + conversation.push(msg); + } + + Ok(()) + } + + async fn generate_summary( + &self, + conversation: &[AgentMessage], + provider: &dyn IntelligenceProvider, + ) -> Result { + let conversation_text: String = conversation + .iter() + .map(|m| { + let role = &m.role; + let content = if m.content.len() > 500 { + &m.content[..500] + } else { + &m.content + }; + format!("{}: {}", role, content) + }) + .collect::>() + .join("\n"); + + let request = GenerationRequest { + mode: GenerationMode::PlanGeneration, + system_prompt: "Summarize the following conversation concisely. Keep key decisions, file paths, tool results, and current state. Output only the summary.".to_string(), + user_prompt: conversation_text, + max_tokens: Some(self.policy.max_summary_tokens as i64), + temperature: Some(0.1), + }; + + let response = provider.generate(&request)?; + Ok(response.content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_compact_at_threshold() { + let service = CompactionService::new(CompactionPolicy { + auto_compact_threshold: 0.85, + ..Default::default() + }); + assert!(!service.should_compact(84, 100)); + assert!(service.should_compact(85, 100)); + assert!(service.should_compact(100, 100)); + } + + #[test] + fn should_not_compact_empty_context() { + let service = CompactionService::new(CompactionPolicy::default()); + assert!(!service.should_compact(100, 0)); + } +} diff --git a/crates/codra-core/src/cost_tracker.rs b/crates/codra-core/src/cost_tracker.rs new file mode 100644 index 0000000..d78ce6a --- /dev/null +++ b/crates/codra-core/src/cost_tracker.rs @@ -0,0 +1,67 @@ +use crate::agent_loop::AgentMessage; +use crate::provider::IntelligenceProvider; + +pub struct CostTracker { + total_prompt_tokens: i64, + total_completion_tokens: i64, + total_cost_usd: f64, + cost_per_1k_prompt: f64, + cost_per_1k_completion: f64, +} + +impl CostTracker { + pub fn new(cost_per_1k_prompt: f64, cost_per_1k_completion: f64) -> Self { + Self { + total_prompt_tokens: 0, + total_completion_tokens: 0, + total_cost_usd: 0.0, + cost_per_1k_prompt, + cost_per_1k_completion, + } + } + + pub fn record_usage(&mut self, usage: &codra_protocol::TokenUsage) { + self.total_prompt_tokens += usage.prompt_tokens; + self.total_completion_tokens += usage.completion_tokens; + self.total_cost_usd += (usage.prompt_tokens as f64 / 1000.0) * self.cost_per_1k_prompt; + self.total_cost_usd += + (usage.completion_tokens as f64 / 1000.0) * self.cost_per_1k_completion; + } + + pub fn summary(&self) -> CostSummary { + CostSummary { + total_prompt_tokens: self.total_prompt_tokens, + total_completion_tokens: self.total_completion_tokens, + total_tokens: self.total_prompt_tokens + self.total_completion_tokens, + total_cost_usd: (self.total_cost_usd * 100.0).round() / 100.0, + } + } + + pub fn reset(&mut self) { + self.total_prompt_tokens = 0; + self.total_completion_tokens = 0; + self.total_cost_usd = 0.0; + } +} + +impl Default for CostTracker { + fn default() -> Self { + Self::new(0.0, 0.0) + } +} + +#[derive(Debug, Clone)] +pub struct CostSummary { + pub total_prompt_tokens: i64, + pub total_completion_tokens: i64, + pub total_tokens: i64, + pub total_cost_usd: f64, +} + +pub struct TurnMetrics { + pub turn_number: usize, + pub token_usage: codra_protocol::TokenUsage, + pub tool_calls: usize, + pub duration_ms: u128, + pub cost: CostSummary, +} diff --git a/crates/codra-core/src/file_changes.rs b/crates/codra-core/src/file_changes.rs index 55eed43..bca2ddb 100644 --- a/crates/codra-core/src/file_changes.rs +++ b/crates/codra-core/src/file_changes.rs @@ -6,7 +6,7 @@ pub fn validate_file_change(workspace_path: &Path, change: &FileChange) -> Resul let full_path = workspace_path.join(&change.path); // Block absolute paths and path traversal - if change.path.starts_with('/') || change.path.contains("..") { + if change.change_type.starts_with('/') || change.change_type.contains("..") { return Err("Path traversal or absolute path outside workspace is not allowed".to_string()); } @@ -38,6 +38,7 @@ pub fn apply_file_change( workspace_path: &Path, change: &mut FileChange, backup_dir: &Path, + content: Option<&str>, ) -> Result<(), String> { validate_file_change(workspace_path, change)?; @@ -49,15 +50,50 @@ pub fn apply_file_change( match change.change_type.as_str() { "create" | "modify" => { - fs::write(&full_path, "").map_err(|e| e.to_string())?; // placeholder - real patch later + let file_content = content.unwrap_or(""); + fs::write(&full_path, file_content).map_err(|e| e.to_string())?; change.applied = true; } "delete" => { - // Block delete by default for safety in MVP - return Err("Delete operations are blocked by default in MVP".to_string()); + if full_path.exists() { + fs::remove_file(&full_path).map_err(|e| e.to_string())?; + change.applied = true; + } } _ => return Err("Unsupported change type".to_string()), } Ok(()) } + +pub fn apply_search_replace( + workspace_path: &Path, + path: &str, + search: &str, + replace: &str, + backup_dir: &Path, +) -> Result { + let full_path = workspace_path.join(path); + if !full_path.starts_with(workspace_path) { + return Err("Path traversal not allowed".to_string()); + } + + if !full_path.exists() { + return Err(format!("File not found: {}", path)); + } + + let content = fs::read_to_string(&full_path).map_err(|e| e.to_string())?; + + let _ = create_backup(workspace_path, path, backup_dir); + + let new_content = content.replace(search, replace); + + if content == new_content { + return Ok(format!("No matches found for '{}' in {}", search, path)); + } + + let matches = content.matches(search).count(); + fs::write(&full_path, &new_content).map_err(|e| e.to_string())?; + + Ok(format!("Replaced {} occurrence(s) in {}", matches, path)) +} diff --git a/crates/codra-core/src/git_tools.rs b/crates/codra-core/src/git_tools.rs new file mode 100644 index 0000000..d4e49ec --- /dev/null +++ b/crates/codra-core/src/git_tools.rs @@ -0,0 +1,210 @@ +use crate::tool_dispatcher::DynTool; +use async_trait::async_trait; +use codra_protocol::{ToolCategory, ToolDefinition, ToolSafetyLevel}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +pub struct GitBranchTool { + root: PathBuf, +} + +impl GitBranchTool { + pub fn new(workspace: &str) -> Self { + Self { + root: PathBuf::from(workspace), + } + } + + async fn run_git(&self, args: &[&str]) -> Result { + let output = tokio::process::Command::new("git") + .args(args) + .current_dir(&self.root) + .output() + .await + .map_err(|e| format!("Failed to run git: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Git error: {}", stderr)); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} + +#[async_trait] +impl DynTool for GitBranchTool { + fn name(&self) -> &str { + "git.branch" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "git.branch".to_string(), + display_name: "Git branch operations".to_string(), + description: "Create, list, or switch git branches.".to_string(), + category: ToolCategory::Git, + safety_level: ToolSafetyLevel::WorkspaceWrite, + input_schema: json!({ + "type": "object", + "properties": { + "action": { "type": "string", "enum": ["create", "list", "switch"] }, + "branch_name": { "type": "string" } + }, + "required": ["action"] + }), + } + } + + async fn execute(&self, args: &Value) -> Result { + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or("Missing 'action' argument")?; + + match action { + "list" => self.run_git(&["branch", "--list"]).await, + "create" => { + let name = args + .get("branch_name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'branch_name' for create")?; + self.run_git(&["checkout", "-b", name]).await + } + "switch" => { + let name = args + .get("branch_name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'branch_name' for switch")?; + self.run_git(&["checkout", name]).await + } + _ => Err(format!("Unknown action: {}", action)), + } + } +} + +pub struct GitCommitTool { + root: PathBuf, +} + +impl GitCommitTool { + pub fn new(workspace: &str) -> Self { + Self { + root: PathBuf::from(workspace), + } + } +} + +#[async_trait] +impl DynTool for GitCommitTool { + fn name(&self) -> &str { + "git.commit" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "git.commit".to_string(), + display_name: "Git commit".to_string(), + description: "Stage all changes and create a commit.".to_string(), + category: ToolCategory::Git, + safety_level: ToolSafetyLevel::WorkspaceWrite, + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"] + }), + } + } + + async fn execute(&self, args: &Value) -> Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or("Missing 'message' argument")?; + + let output = tokio::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(&self.root) + .output() + .await + .map_err(|e| format!("Failed to run git add: {}", e))?; + + if !output.status.success() { + return Err(format!( + "git add failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let output = tokio::process::Command::new("git") + .args(["commit", "-m", message]) + .current_dir(&self.root) + .output() + .await + .map_err(|e| format!("Failed to run git commit: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("nothing to commit") { + return Ok("Nothing to commit".to_string()); + } + return Err(format!("git commit failed: {}", stderr)); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} + +pub struct GitStatusTool { + root: PathBuf, +} + +impl GitStatusTool { + pub fn new(workspace: &str) -> Self { + Self { + root: PathBuf::from(workspace), + } + } +} + +#[async_trait] +impl DynTool for GitStatusTool { + fn name(&self) -> &str { + "git.status" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "git.status".to_string(), + display_name: "Git status".to_string(), + description: "Get the current git status including branch and changed files.".to_string(), + category: ToolCategory::Git, + safety_level: ToolSafetyLevel::ReadOnly, + input_schema: json!({ "type": "object", "properties": {} }), + } + } + + async fn execute(&self, _args: &Value) -> Result { + let output = tokio::process::Command::new("git") + .args(["status", "--short"]) + .current_dir(&self.root) + .output() + .await + .map_err(|e| format!("Failed to run git: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + let branch_output = tokio::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(&self.root) + .output() + .await + .map_err(|e| format!("Failed to run git: {}", e))?; + + let branch = String::from_utf8_lossy(&branch_output.stdout).trim().to_string(); + + Ok(format!("Branch: {}\nChanges:\n{}", branch, stdout)) + } +} diff --git a/crates/codra-core/src/history_repair.rs b/crates/codra-core/src/history_repair.rs new file mode 100644 index 0000000..d0f452d --- /dev/null +++ b/crates/codra-core/src/history_repair.rs @@ -0,0 +1,146 @@ +use std::path::{Path, PathBuf}; + +pub struct HistoryRepair { + history_path: PathBuf, +} + +#[derive(Debug)] +pub struct RepairReport { + pub orphaned_tool_results: usize, + pub duplicated_entries: usize, + pub synthetic_results_inserted: usize, + pub total_entries_processed: usize, +} + +impl HistoryRepair { + pub fn new(history_path: impl Into) -> Self { + Self { + history_path: history_path.into(), + } + } + + pub fn repair(&self) -> Result { + if !self.history_path.exists() { + return Ok(RepairReport { + orphaned_tool_results: 0, + duplicated_entries: 0, + synthetic_results_inserted: 0, + total_entries_processed: 0, + }); + } + + let content = std::fs::read_to_string(&self.history_path) + .map_err(|e| format!("Failed to read history: {}", e))?; + + let mut lines: Vec = content.lines().map(|l| l.to_string()).collect(); + let mut report = RepairReport { + orphaned_tool_results: 0, + duplicated_entries: 0, + synthetic_results_inserted: 0, + total_entries_processed: lines.len(), + }; + + let mut tool_call_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut tool_result_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut entries_to_remove: Vec = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + if let Ok(entry) = serde_json::from_str::(line) { + if let Some(role) = entry.get("role").and_then(|v| v.as_str()) { + if role == "assistant" { + if let Some(calls) = entry.get("tool_calls").and_then(|v| v.as_array()) { + for call in calls { + if let Some(id) = call.get("id").and_then(|v| v.as_str()) { + tool_call_ids.insert(id.to_string()); + } + } + } + } else if role == "tool" { + if let Some(id) = entry.get("tool_call_id").and_then(|v| v.as_str()) { + if tool_result_ids.contains(id) { + report.duplicated_entries += 1; + entries_to_remove.push(i); + } else { + tool_result_ids.insert(id.to_string()); + } + } + } + } + } + } + + for id in &tool_call_ids { + if !tool_result_ids.contains(id) { + let synthetic = serde_json::json!({ + "role": "tool", + "content": "[Synthetic result - original was lost]", + "tool_call_id": id + }); + lines.push(serde_json::to_string(&synthetic).unwrap_or_default()); + report.synthetic_results_inserted += 1; + } + } + + report.orphaned_tool_results = tool_result_ids + .iter() + .filter(|id| !tool_call_ids.contains(*id)) + .count(); + + for i in entries_to_remove.iter().rev() { + lines.remove(*i); + } + + let repaired: Vec<&str> = lines.iter().map(|l| l.as_str()).collect(); + std::fs::write(&self.history_path, repaired.join("\n")) + .map_err(|e| format!("Failed to write repaired history: {}", e))?; + + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn repair_orphaned_tool_result() { + let dir = TempDir::new().unwrap(); + let history_path = dir.path().join("chat.jsonl"); + + let lines = vec![ + serde_json::json!({"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function"}]}), + serde_json::json!({"role": "tool", "content": "result", "tool_call_id": "call_1"}), + serde_json::json!({"role": "assistant", "tool_calls": [{"id": "call_2", "type": "function"}]}), + ]; + + let content: Vec = lines.iter().map(|l| serde_json::to_string(l).unwrap()).collect(); + std::fs::write(&history_path, content.join("\n")).unwrap(); + + let repair = HistoryRepair::new(&history_path); + let report = repair.repair().unwrap(); + + assert_eq!(report.synthetic_results_inserted, 1); + assert_eq!(report.total_entries_processed, 3); + } + + #[test] + fn repair_deduplicates() { + let dir = TempDir::new().unwrap(); + let history_path = dir.path().join("chat.jsonl"); + + let lines = vec![ + serde_json::json!({"role": "assistant", "tool_calls": [{"id": "call_1"}]}), + serde_json::json!({"role": "tool", "content": "r1", "tool_call_id": "call_1"}), + serde_json::json!({"role": "tool", "content": "r1_dup", "tool_call_id": "call_1"}), + ]; + + let content: Vec = lines.iter().map(|l| serde_json::to_string(l).unwrap()).collect(); + std::fs::write(&history_path, content.join("\n")).unwrap(); + + let repair = HistoryRepair::new(&history_path); + let report = repair.repair().unwrap(); + + assert_eq!(report.duplicated_entries, 1); + } +} diff --git a/crates/codra-core/src/hooks.rs b/crates/codra-core/src/hooks.rs new file mode 100644 index 0000000..114606d --- /dev/null +++ b/crates/codra-core/src/hooks.rs @@ -0,0 +1,163 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub enum HookEvent<'a> { + PreToolUse { + tool_name: String, + arguments: Value, + }, + PostToolUse { + tool_name: String, + success: bool, + }, + SessionStart, + SessionEnd, + PreCompact, + PostCompact, + UserPromptSubmit { + prompt: &'a str, + }, +} + +#[derive(Debug, Clone)] +pub enum HookDecision { + Allow, + Deny { reason: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookConfig { + pub event: String, + pub matcher: Option, + pub handler: HookHandler, + #[serde(default = "default_fail_open")] + pub fail_open: bool, +} + +fn default_fail_open() -> bool { + true +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HookHandler { + Shell { command: String }, + Http { url: String }, +} + +pub struct HookSystem { + hooks: Vec, +} + +impl HookSystem { + pub fn new() -> Self { + Self { hooks: vec![] } + } + + pub fn with_hooks(mut self, hooks: Vec) -> Self { + self.hooks = hooks; + self + } + + pub fn load_from_dir(&mut self, dir: &std::path::Path) { + if !dir.exists() { + return; + } + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(hook) = serde_json::from_str::(&content) { + self.hooks.push(hook); + } + } + } + } + } + } + + pub async fn dispatch(&self, event: HookEvent<'_>) -> HookDecision { + let event_name = match &event { + HookEvent::PreToolUse { .. } => "pre_tool_use", + HookEvent::PostToolUse { .. } => "post_tool_use", + HookEvent::SessionStart => "session_start", + HookEvent::SessionEnd => "session_end", + HookEvent::PreCompact => "pre_compact", + HookEvent::PostCompact => "post_compact", + HookEvent::UserPromptSubmit { .. } => "user_prompt_submit", + }; + + let tool_name = match &event { + HookEvent::PreToolUse { tool_name, .. } => Some(tool_name.as_str()), + HookEvent::PostToolUse { tool_name, .. } => Some(tool_name.as_str()), + _ => None, + }; + + for hook in &self.hooks { + if hook.event != event_name { + continue; + } + + if let Some(ref matcher) = hook.matcher { + if let Some(name) = tool_name { + if !name.contains(matcher.as_str()) { + continue; + } + } else { + continue; + } + } + + match self.execute_handler(&hook.handler).await { + Ok(output) => { + if event_name == "pre_tool_use" && output.contains("DENY") { + return HookDecision::Deny { + reason: output, + }; + } + } + Err(e) => { + if !hook.fail_open { + return HookDecision::Deny { + reason: format!("Hook failed: {}", e), + }; + } + } + } + } + + HookDecision::Allow + } + + async fn execute_handler(&self, handler: &HookHandler) -> Result { + match handler { + HookHandler::Shell { command } => { + let output = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .output() + .await + .map_err(|e| format!("Failed to run hook: {}", e))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(stdout) + } + HookHandler::Http { url } => { + let client = reqwest::Client::new(); + let resp = client + .post(url) + .send() + .await + .map_err(|e| format!("Hook HTTP request failed: {}", e))?; + let body = resp.text().await.unwrap_or_default(); + Ok(body) + } + } + } +} + +impl Default for HookSystem { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/codra-core/src/lib.rs b/crates/codra-core/src/lib.rs index 0800ca6..09e5beb 100644 --- a/crates/codra-core/src/lib.rs +++ b/crates/codra-core/src/lib.rs @@ -1,14 +1,25 @@ -// pub mod architect; +pub mod agent_loop; +pub mod compaction; pub mod config; +pub mod cost_tracker; pub mod deploy; pub mod executor; +pub mod git_tools; +pub mod history_repair; +pub mod hooks; pub mod planner; pub mod prompts; pub mod provider; pub mod provider_config; pub mod repair; +pub mod sandbox; pub mod services; +pub mod session; +pub mod subagent; pub mod task_store; +pub mod token_counter; +pub mod tool_dispatcher; +pub mod tools_impl; pub mod verifier; pub struct ExecutionContext { diff --git a/crates/codra-core/src/provider.rs b/crates/codra-core/src/provider.rs index ca697cf..f9b54a9 100644 --- a/crates/codra-core/src/provider.rs +++ b/crates/codra-core/src/provider.rs @@ -12,6 +12,21 @@ pub trait IntelligenceProvider: Send + Sync { fn generate(&self, request: &GenerationRequest) -> Result; fn health_check(&self) -> Result; fn list_models(&self) -> Result, String>; + fn generate_streaming( + &self, + request: &GenerationRequest, + ) -> Result> + Send>, String> { + let response = self.generate(request)?; + Ok(Box::new(std::iter::once(Ok(response.content)))) + } +} + +// ===== STREAMING RESPONSE ===== + +pub struct StreamingChunk { + pub delta: String, + pub finish_reason: Option, + pub usage: Option, } // ===== HIGHER-LEVEL TRAITS FOR SUBSYSTEM CONSUMPTION ===== @@ -444,6 +459,270 @@ impl IntelligenceProvider for EchoMockProvider { } } +// ===== TERA API ADAPTER ===== + +pub struct TeraProvider { + pub base_url: String, + pub model: String, + pub api_key: Option, +} + +impl TeraProvider { + pub fn new(base_url: &str, model: &str, api_key: Option) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + model: model.to_string(), + api_key, + } + } +} + +#[derive(Serialize)] +struct TeraChatRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + stream: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +struct TeraMessage { + role: String, + content: String, +} + +#[derive(Deserialize)] +struct TeraChatResponse { + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct TeraChoice { + message: TeraMessage, + finish_reason: Option, +} + +#[derive(Deserialize)] +struct TeraUsage { + prompt_tokens: i64, + completion_tokens: i64, + total_tokens: i64, +} + +impl IntelligenceProvider for TeraProvider { + fn generate(&self, request: &GenerationRequest) -> Result { + let url = format!("{}/v1/chat/completions", self.base_url); + let body = TeraChatRequest { + model: self.model.clone(), + messages: vec![ + TeraMessage { + role: "system".to_string(), + content: request.system_prompt.clone(), + }, + TeraMessage { + role: "user".to_string(), + content: request.user_prompt.clone(), + }, + ], + max_tokens: request.max_tokens, + temperature: request.temperature, + stream: false, + }; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("HTTP client init failed: {}", e))?; + + let mut req_builder = client.post(&url).json(&body); + if let Some(key) = &self.api_key { + req_builder = req_builder.header("Authorization", format!("Bearer {}", key)); + } + + let resp = req_builder + .send() + .map_err(|e| format!("Tera request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().unwrap_or_default(); + return Err(format!("Tera returned HTTP {}: {}", status, body_text)); + } + + let chat_resp: TeraChatResponse = + resp.json().map_err(|e| format!("Failed to parse Tera response: {}", e))?; + let choice = chat_resp + .choices + .first() + .ok_or("Empty response from Tera")?; + + let usage = chat_resp.usage.map(|u| TokenUsage { + prompt_tokens: u.prompt_tokens, + completion_tokens: u.completion_tokens, + total_tokens: u.total_tokens, + }); + + Ok(GenerationResponse { + content: choice.message.content.clone(), + finish_reason: choice.finish_reason.clone(), + token_usage: usage, + }) + } + + fn health_check(&self) -> Result { + let url = format!("{}/v1/models", self.base_url); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| e.to_string())?; + + let mut req = client.get(&url); + if let Some(key) = &self.api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + match req.send() { + Ok(resp) if resp.status().is_success() => Ok(ProviderHealthResult { + reachable: true, + model_available: true, + status: ProviderStatus::Connected, + message: format!( + "Connected to Tera at {} with model '{}'", + self.base_url, self.model + ), + }), + Ok(resp) => Ok(ProviderHealthResult { + reachable: true, + model_available: false, + status: ProviderStatus::Failed, + message: format!("Tera returned HTTP {}", resp.status()), + }), + Err(e) => Ok(ProviderHealthResult { + reachable: false, + model_available: false, + status: ProviderStatus::Failed, + message: format!("Cannot reach Tera at {}: {}", self.base_url, e), + }), + } + } + + fn list_models(&self) -> Result, String> { + let url = format!("{}/v1/models", self.base_url); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| e.to_string())?; + + let mut req = client.get(&url); + if let Some(key) = &self.api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let resp = req.send().map_err(|e| format!("Failed: {}", e))?; + let models_resp: TeraModelsResponse = + resp.json().map_err(|e| format!("Parse error: {}", e))?; + + Ok(models_resp + .data + .iter() + .map(|m| ModelDescriptor { + id: m.id.clone(), + name: m.id.clone(), + context_length: None, + supports_tools: false, + supports_vision: false, + }) + .collect()) + } + + fn generate_streaming( + &self, + request: &GenerationRequest, + ) -> Result> + Send>, String> { + let url = format!("{}/v1/chat/completions", self.base_url); + let body = TeraChatRequest { + model: self.model.clone(), + messages: vec![ + TeraMessage { + role: "system".to_string(), + content: request.system_prompt.clone(), + }, + TeraMessage { + role: "user".to_string(), + content: request.user_prompt.clone(), + }, + ], + max_tokens: request.max_tokens, + temperature: request.temperature, + stream: true, + }; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("HTTP client init failed: {}", e))?; + + let mut req_builder = client.post(&url).json(&body); + if let Some(key) = &self.api_key { + req_builder = req_builder.header("Authorization", format!("Bearer {}", key)); + } + + let resp = req_builder + .send() + .map_err(|e| format!("Tera streaming request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().unwrap_or_default(); + return Err(format!("Tera returned HTTP {}: {}", status, body_text)); + } + + let reader = resp; + let chunks: Vec> = reader + .text() + .map_err(|e| format!("Failed to read stream: {}", e))? + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.starts_with("data: ") { + let data = &line[6..]; + if data == "[DONE]" { + return None; + } + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(delta) = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("content")) + .and_then(|c| c.as_str()) + { + return Some(Ok(delta.to_string())); + } + } + } + None + }) + .collect(); + + Ok(Box::new(chunks.into_iter())) + } +} + +#[derive(Deserialize)] +struct TeraModelsResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct TeraModelEntry { + id: String, +} + // ===== PROVIDER FACTORY ===== pub fn create_provider( @@ -463,9 +742,25 @@ pub fn create_provider( &config.model_id, api_key.map(|s| s.to_string()), )), + ProviderKind::Tera => Box::new(TeraProvider::new( + &config.base_url, + &config.model_id, + api_key.map(|s| s.to_string()), + )), } } +pub fn create_tera_provider( + model: &str, + api_key: Option<&str>, +) -> Box { + Box::new(TeraProvider::new( + "https://teraai.chat", + model, + api_key.map(|s| s.to_string()), + )) +} + // ===== SUBSYSTEM BRIDGE ===== // This struct bridges the unified IntelligenceProvider to the older subsystem traits diff --git a/crates/codra-core/src/repair.rs b/crates/codra-core/src/repair.rs index 55dd42f..238823b 100644 --- a/crates/codra-core/src/repair.rs +++ b/crates/codra-core/src/repair.rs @@ -7,52 +7,62 @@ use codra_protocol::{ use uuid::Uuid; pub struct RepairService<'a> { - _workspace_root: String, + workspace_root: String, provider: &'a dyn IntelligenceProvider, + max_retries: i32, + attempt_history: Vec, } impl<'a> RepairService<'a> { pub fn new(workspace_root: &str, provider: &'a dyn IntelligenceProvider) -> Self { Self { - _workspace_root: workspace_root.to_string(), + workspace_root: workspace_root.to_string(), provider, + max_retries: 3, + attempt_history: vec![], } } + pub fn with_max_retries(mut self, max: i32) -> Self { + self.max_retries = max; + self + } + + pub fn attempt_count(&self) -> i32 { + self.attempt_history.len() as i32 + } + + pub fn budget_exhausted(&self) -> bool { + self.attempt_history.len() as i32 >= self.max_retries + } + pub fn construct_repair_attempt( - &self, + &mut self, retry_request: &RetryRequest, ) -> Result { - let attempt_number = 1; - let max_retries = 3; + let attempt_number = self.attempt_history.len() as i32 + 1; - if attempt_number > max_retries { - return Err("Repair budget exhausted for this verification boundary.".to_string()); + if attempt_number > self.max_retries { + return Err(format!( + "Repair budget exhausted: {} attempts used out of {}", + self.attempt_history.len(), + self.max_retries + )); } - // Use real provider to generate targeted repair diff + let context = self.build_repair_context(retry_request, attempt_number); + let request = GenerationRequest { mode: GenerationMode::RepairGeneration, system_prompt: crate::prompts::REPAIR_SYSTEM_PROMPT.to_string(), - user_prompt: format!( - "Failure summary: {}\nSuggested scope: {}\nAffected findings:\n{}", - retry_request.failure_summary, - retry_request.suggested_scope, - retry_request - .findings - .iter() - .map(|f| format!("- [{}] {}", format!("{:?}", f.classification), f.message)) - .collect::>() - .join("\n") - ), - max_tokens: Some(1024), + user_prompt: context, + max_tokens: Some(2048), temperature: Some(0.2), }; let diff_content = match self.provider.generate(&request) { Ok(response) => response.content, Err(e) => { - // Explicit fallback: still produce the attempt structure but note the failure format!( "// Provider repair generation failed: {}\n// Manual intervention required", e @@ -71,21 +81,114 @@ impl<'a> RepairService<'a> { step_id: retry_request.step_id.clone(), target_file, rationale: format!( - "Attempt {}: {}", - attempt_number, retry_request.suggested_scope + "Attempt {}/{}: {}", + attempt_number, self.max_retries, retry_request.suggested_scope ), diff_content, status: PatchProposalStatus::ReadyForReview, timestamp: Utc::now().to_rfc3339(), }; - Ok(RepairAttempt { + let attempt = RepairAttempt { id: Uuid::new_v4().to_string(), verification_id: retry_request.verification_id.clone(), status: RepairAttemptStatus::AwaitingApproval, proposed_patch: Some(patch), error: None, attempt_number, - }) + }; + + self.attempt_history.push(attempt.clone()); + + Ok(attempt) + } + + fn build_repair_context(&self, retry_request: &RetryRequest, attempt_number: i32) -> String { + let previous_attempts: String = self + .attempt_history + .iter() + .map(|a| { + format!( + "Attempt {}: status={:?}, error={}", + a.attempt_number, + a.status, + a.error.as_deref().unwrap_or("none") + ) + }) + .collect::>() + .join("\n"); + + format!( + "Failure summary: {}\nSuggested scope: {}\nAttempt: {}/{}\nPrevious attempts:\n{}\nAffected findings:\n{}", + retry_request.failure_summary, + retry_request.suggested_scope, + attempt_number, + self.max_retries, + if previous_attempts.is_empty() { + "None".to_string() + } else { + previous_attempts + }, + retry_request + .findings + .iter() + .map(|f| format!( + "- [{}] {} (files: {:?})", + format!("{:?}", f.classification), + f.message, + f.affected_files + )) + .collect::>() + .join("\n") + ) + } + + pub fn reset(&mut self) { + self.attempt_history.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::EchoMockProvider; + use codra_protocol::{FailureClassification, VerificationFinding, VerificationSeverity}; + + #[test] + fn repair_budget_tracking() { + let provider = EchoMockProvider::new(); + let mut service = RepairService::new("/tmp", &provider).with_max_retries(2); + + assert!(!service.budget_exhausted()); + assert_eq!(service.attempt_count(), 0); + + let retry = RetryRequest { + id: "r1".to_string(), + verification_id: "v1".to_string(), + execution_id: "e1".to_string(), + step_id: "s1".to_string(), + failure_summary: "test failed".to_string(), + findings: vec![VerificationFinding { + id: "f1".to_string(), + severity: VerificationSeverity::Critical, + classification: FailureClassification::TestFailure, + message: "test error".to_string(), + affected_files: vec!["src/main.rs".to_string()], + }], + suggested_scope: "fix test".to_string(), + }; + + let result = service.construct_repair_attempt(&retry); + assert!(result.is_ok()); + assert_eq!(service.attempt_count(), 1); + assert!(!service.budget_exhausted()); + + let result2 = service.construct_repair_attempt(&retry); + assert!(result2.is_ok()); + assert_eq!(service.attempt_count(), 2); + assert!(service.budget_exhausted()); + + let result3 = service.construct_repair_attempt(&retry); + assert!(result3.is_err()); } } diff --git a/crates/codra-core/src/sandbox.rs b/crates/codra-core/src/sandbox.rs new file mode 100644 index 0000000..fad463d --- /dev/null +++ b/crates/codra-core/src/sandbox.rs @@ -0,0 +1,122 @@ +use std::collections::HashSet; + +pub struct CommandSandbox { + blocked_patterns: Vec, + allowed_commands: Vec, +} + +impl CommandSandbox { + pub fn new() -> Self { + Self { + blocked_patterns: vec![ + "rm -rf /".to_string(), + "sudo".to_string(), + "git push --force".to_string(), + "npm publish".to_string(), + "yarn publish".to_string(), + "ssh".to_string(), + "curl | sh".to_string(), + "curl | bash".to_string(), + "wget | sh".to_string(), + "chmod 777".to_string(), + "mkfs".to_string(), + "dd if=".to_string(), + ":(){ :|:& };:".to_string(), + ], + allowed_commands: vec![ + "cargo check".to_string(), + "cargo test".to_string(), + "cargo build".to_string(), + "pnpm install".to_string(), + "pnpm build".to_string(), + "pnpm test".to_string(), + "pnpm lint".to_string(), + "npm install".to_string(), + "npm run build".to_string(), + "npm test".to_string(), + "git status".to_string(), + "git diff".to_string(), + "git log".to_string(), + "git branch".to_string(), + ], + } + } + + pub fn check_command(&self, command: &str) -> Result { + for pattern in &self.blocked_patterns { + if command.contains(pattern.as_str()) { + return Err(format!( + "Command blocked by sandbox: matches pattern '{}'", + pattern + )); + } + } + + let cmd_parts: Vec<&str> = command.split_whitespace().collect(); + if cmd_parts.is_empty() { + return Err("Empty command".to_string()); + } + + if self.allowed_commands.iter().any(|allowed| { + let allowed_parts: Vec<&str> = allowed.split_whitespace().collect(); + cmd_parts[..allowed_parts.len().min(cmd_parts.len())] == allowed_parts[..] + }) { + return Ok(SandboxVerdict::Allowed); + } + + Ok(SandboxVerdict::RequiresReview { + reason: format!( + "Command '{}' is not in the allowlist. Review required.", + cmd_parts[0] + ), + }) + } + + pub fn wrap_command(&self, command: &str) -> Result { + self.check_command(command)?; + Ok(format!( + "set -euo pipefail; timeout 120 {}", + command + )) + } +} + +impl Default for CommandSandbox { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +pub enum SandboxVerdict { + Allowed, + RequiresReview { reason: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocked_commands() { + let sandbox = CommandSandbox::new(); + assert!(sandbox.check_command("rm -rf /").is_err()); + assert!(sandbox.check_command("sudo apt install").is_err()); + } + + #[test] + fn allowed_commands() { + let sandbox = CommandSandbox::new(); + assert!(sandbox.check_command("cargo check").is_ok()); + assert!(sandbox.check_command("pnpm test").is_ok()); + } + + #[test] + fn unknown_commands_require_review() { + let sandbox = CommandSandbox::new(); + match sandbox.check_command("python run_script.py") { + Ok(SandboxVerdict::RequiresReview { .. }) => {} + _ => panic!("Expected RequiresReview"), + } + } +} diff --git a/crates/codra-core/src/session.rs b/crates/codra-core/src/session.rs new file mode 100644 index 0000000..6515ccb --- /dev/null +++ b/crates/codra-core/src/session.rs @@ -0,0 +1,127 @@ +use crate::agent_loop::{AgentConfig, AgentLoop, AgentLoopResult, AgentMessage}; +use crate::tool_dispatcher::ToolDispatcher; +use crate::provider::IntelligenceProvider; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; + +pub enum SessionCommand { + Prompt { + content: String, + respond_to: oneshot::Sender>, + }, + Cancel, + Shutdown, +} + +pub enum SessionNotification { + TurnStarted { turn: usize }, + TurnCompleted { result: AgentLoopResult }, + ToolCallStarted { tool_name: String }, + ToolCallCompleted { tool_name: String, success: bool }, + Compacted { message_count: usize }, + Error { message: String }, +} + +pub struct SessionActor { + command_rx: mpsc::Receiver, + notification_tx: mpsc::Sender, + conversation: Vec, + agent_config: AgentConfig, +} + +impl SessionActor { + pub fn new( + command_rx: mpsc::Receiver, + notification_tx: mpsc::Sender, + agent_config: AgentConfig, + ) -> Self { + Self { + command_rx, + notification_tx, + conversation: vec![], + agent_config, + } + } + + pub async fn run( + &mut self, + provider: Arc, + dispatcher: Arc>, + ) { + while let Some(command) = self.command_rx.recv().await { + match command { + SessionCommand::Prompt { + content, + respond_to, + } => { + let result = self + .handle_prompt(&content, &provider, &dispatcher) + .await; + let _ = respond_to.send(result); + } + SessionCommand::Cancel => { + self.conversation.clear(); + } + SessionCommand::Shutdown => { + break; + } + } + } + } + + async fn handle_prompt( + &mut self, + prompt: &str, + provider: &Arc, + dispatcher: &Arc>, + ) -> Result { + let _ = self + .notification_tx + .send(SessionNotification::TurnStarted { + turn: self.conversation.len(), + }) + .await; + + let agent = AgentLoop::new(provider.as_ref(), dispatcher.clone(), self.agent_config.clone()); + let result = agent.run(prompt, &mut self.conversation).await; + + let _ = self + .notification_tx + .send(SessionNotification::TurnCompleted { + result: result.clone().unwrap_or_else(|e| AgentLoopResult { + final_text: format!("Error: {}", e), + turns: 0, + tool_calls_made: 0, + token_usage: codra_protocol::TokenUsage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + messages: vec![], + }), + }) + .await; + + result + } +} + +pub fn spawn_session( + provider: Arc, + dispatcher: Arc>, + config: AgentConfig, +) -> ( + mpsc::Sender, + mpsc::Receiver, +) { + let (cmd_tx, cmd_rx) = mpsc::channel(32); + let (notif_tx, notif_rx) = mpsc::channel(64); + + let mut actor = SessionActor::new(cmd_rx, notif_tx, config); + + tokio::spawn(async move { + actor.run(provider, dispatcher).await; + }); + + (cmd_tx, notif_rx) +} diff --git a/crates/codra-core/src/subagent.rs b/crates/codra-core/src/subagent.rs new file mode 100644 index 0000000..0da9754 --- /dev/null +++ b/crates/codra-core/src/subagent.rs @@ -0,0 +1,79 @@ +use crate::agent_loop::{AgentConfig, AgentLoop, AgentMessage, AgentLoopResult}; +use crate::provider::IntelligenceProvider; +use crate::tool_dispatcher::{DynTool, ToolDispatcher, CodraToolDispatcher}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubagentRequest { + pub task: String, + pub agent_type: SubagentType, + pub workspace_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SubagentType { + GeneralPurpose, + Explore, + Plan, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubagentResult { + pub task_id: String, + pub status: String, + pub output: String, + pub tool_calls_made: usize, +} + +pub struct SubagentSpawner { + provider: Arc, +} + +impl SubagentSpawner { + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + pub async fn spawn( + &self, + request: SubagentRequest, + ) -> Result { + let task_id = uuid::Uuid::new_v4().to_string(); + + let system_prompt = match request.agent_type { + SubagentType::GeneralPurpose => { + "You are a general-purpose coding subagent. Execute the given task autonomously using available tools. Report what you did and any files changed." + } + SubagentType::Explore => { + "You are a read-only exploration subagent. Analyze the codebase to answer questions. Do NOT modify any files. Report your findings." + } + SubagentType::Plan => { + "You are a planning subagent. Analyze the task and produce a detailed plan. Do NOT execute changes. Report the plan." + } + }; + + let config = AgentConfig { + system_prompt: system_prompt.to_string(), + max_turns: 20, + max_tokens: Some(4096), + temperature: Some(0.2), + auto_compact_threshold: 0.85, + }; + + let dispatcher: Arc> = + Arc::new(Mutex::new(CodraToolDispatcher::new())); + + let agent = AgentLoop::new(self.provider.as_ref(), dispatcher, config); + let mut conversation = Vec::new(); + let result = agent.run(&request.task, &mut conversation).await?; + + Ok(SubagentResult { + task_id, + status: "completed".to_string(), + output: result.final_text, + tool_calls_made: result.tool_calls_made, + }) + } +} diff --git a/crates/codra-core/src/task_executor.rs b/crates/codra-core/src/task_executor.rs index 202263a..0e3bbb0 100644 --- a/crates/codra-core/src/task_executor.rs +++ b/crates/codra-core/src/task_executor.rs @@ -51,6 +51,7 @@ impl TaskExecutor { std::path::Path::new(&task.workspace_path), change, &backup_dir, + None, )?; } } diff --git a/crates/codra-core/src/token_counter.rs b/crates/codra-core/src/token_counter.rs new file mode 100644 index 0000000..17b965c --- /dev/null +++ b/crates/codra-core/src/token_counter.rs @@ -0,0 +1,88 @@ +pub struct TokenCounter { + context_window: usize, +} + +impl TokenCounter { + pub fn new() -> Self { + Self { + context_window: 128_000, + } + } + + pub fn with_context_window(mut self, window: usize) -> Self { + self.context_window = window; + self + } + + pub fn context_window(&self) -> usize { + self.context_window + } + + pub fn estimate_conversation(&self, messages: &[crate::agent_loop::AgentMessage]) -> usize { + messages + .iter() + .map(|m| self.estimate_message(m)) + .sum() + } + + fn estimate_message(&self, msg: &crate::agent_loop::AgentMessage) -> usize { + let content_tokens = self.estimate_tokens(&msg.content); + let tool_call_tokens: usize = msg + .tool_calls + .iter() + .map(|tc| { + let args_str = serde_json::to_string(&tc.arguments).unwrap_or_default(); + self.estimate_tokens(&tc.tool_name) + self.estimate_tokens(&args_str) + 4 + }) + .sum(); + content_tokens + tool_call_tokens + 4 + } + + fn estimate_tokens(&self, text: &str) -> usize { + // Rough estimate: ~4 chars per token, minimum 1 token for non-empty text + let chars = text.len(); + if chars == 0 { 0 } else { (chars + 3) / 4 } + } +} + +impl Default for TokenCounter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_loop::AgentMessage; + + #[test] + fn estimate_tokens_basic() { + let counter = TokenCounter::new(); + // "hello" = 5 chars -> (5+3)/4 = 2 + assert_eq!(counter.estimate_tokens("hello"), 2); + // "hello world test" = 16 chars -> (16+3)/4 = 4 + assert_eq!(counter.estimate_tokens("hello world test"), 4); + } + + #[test] + fn estimate_conversation() { + let counter = TokenCounter::new(); + let messages = vec![ + AgentMessage { + role: "user".to_string(), + content: "Hello".to_string(), + tool_calls: vec![], + tool_call_id: None, + }, + AgentMessage { + role: "assistant".to_string(), + content: "Hi there!".to_string(), + tool_calls: vec![], + tool_call_id: None, + }, + ]; + let tokens = counter.estimate_conversation(&messages); + assert!(tokens > 0); + } +} diff --git a/crates/codra-core/src/tool_dispatcher.rs b/crates/codra-core/src/tool_dispatcher.rs new file mode 100644 index 0000000..9dc390d --- /dev/null +++ b/crates/codra-core/src/tool_dispatcher.rs @@ -0,0 +1,117 @@ +use async_trait::async_trait; +use codra_protocol::ToolDefinition; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub tool_name: String, + pub arguments: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolOutput { + pub call_id: String, + pub success: bool, + pub output: String, +} + +#[async_trait] +pub trait ToolDispatcher: Send + Sync { + fn tool_definitions(&self) -> Vec; + async fn execute_tool(&mut self, call: &ToolCall) -> Result; +} + +pub struct CodraToolDispatcher { + tools: HashMap>, + file_locks: HashMap>>, +} + +use std::collections::HashMap; +use std::sync::Arc; + +#[async_trait] +pub trait DynTool: Send + Sync { + fn name(&self) -> &str; + fn definition(&self) -> ToolDefinition; + async fn execute(&self, args: &Value) -> Result; +} + +impl CodraToolDispatcher { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + file_locks: HashMap::new(), + } + } + + pub fn register_tool(&mut self, tool: Box) { + self.tools.insert(tool.name().to_string(), tool); + } + + fn get_file_lock(&mut self, path: &str) -> Arc> { + self.file_locks + .entry(path.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } +} + +#[async_trait] +impl ToolDispatcher for CodraToolDispatcher { + fn tool_definitions(&self) -> Vec { + self.tools.values().map(|t| t.definition()).collect() + } + + async fn execute_tool(&mut self, call: &ToolCall) -> Result { + let is_write_tool = matches!( + call.tool_name.as_str(), + "fs.write_checkpointed" | "fs.search_replace" + ); + + if is_write_tool { + let path = call + .arguments + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let lock = self.get_file_lock(&path); + let _guard = lock.lock().await; + + let tool = self + .tools + .get(&call.tool_name) + .ok_or_else(|| format!("Unknown tool: {}", call.tool_name))?; + let result = tool.execute(&call.arguments).await; + drop(_guard); + result.map(|output| ToolOutput { + call_id: call.id.clone(), + success: true, + output, + }) + } else { + let tool = self + .tools + .get(&call.tool_name) + .ok_or_else(|| format!("Unknown tool: {}", call.tool_name))?; + let result = tool.execute(&call.arguments).await; + result.map(|output| ToolOutput { + call_id: call.id.clone(), + success: true, + output, + }) + } + } +} + +use crate::tools_impl::search_replace::SearchReplaceTool; +use crate::tools_impl::fs_read::FsReadTool; + +pub fn create_default_dispatcher(workspace_path: &str) -> CodraToolDispatcher { + let mut dispatcher = CodraToolDispatcher::new(); + dispatcher.register_tool(Box::new(FsReadTool::new(workspace_path))); + dispatcher.register_tool(Box::new(SearchReplaceTool::new(workspace_path))); + dispatcher +} diff --git a/crates/codra-core/src/tools_impl.rs b/crates/codra-core/src/tools_impl.rs new file mode 100644 index 0000000..574ba09 --- /dev/null +++ b/crates/codra-core/src/tools_impl.rs @@ -0,0 +1,2 @@ +pub mod fs_read; +pub mod search_replace; diff --git a/crates/codra-core/src/tools_impl/fs_read.rs b/crates/codra-core/src/tools_impl/fs_read.rs new file mode 100644 index 0000000..024b3e9 --- /dev/null +++ b/crates/codra-core/src/tools_impl/fs_read.rs @@ -0,0 +1,55 @@ +use crate::tool_dispatcher::{DynTool, ToolCall, ToolOutput}; +use async_trait::async_trait; +use codra_protocol::{ToolCategory, ToolDefinition, ToolSafetyLevel}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +pub struct FsReadTool { + root: PathBuf, +} + +impl FsReadTool { + pub fn new(workspace: &str) -> Self { + Self { + root: PathBuf::from(workspace), + } + } +} + +#[async_trait] +impl DynTool for FsReadTool { + fn name(&self) -> &str { + "fs.read" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "fs.read".to_string(), + display_name: "Read file".to_string(), + description: "Read a workspace file without mutating state.".to_string(), + category: ToolCategory::Filesystem, + safety_level: ToolSafetyLevel::ReadOnly, + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + }), + } + } + + async fn execute(&self, args: &Value) -> Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' argument")?; + + let full_path = self.root.join(path); + if !full_path.starts_with(&self.root) { + return Err("Path traversal not allowed".to_string()); + } + + std::fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e)) + } +} diff --git a/crates/codra-core/src/tools_impl/search_replace.rs b/crates/codra-core/src/tools_impl/search_replace.rs new file mode 100644 index 0000000..fb40e74 --- /dev/null +++ b/crates/codra-core/src/tools_impl/search_replace.rs @@ -0,0 +1,165 @@ +use crate::tool_dispatcher::DynTool; +use async_trait::async_trait; +use codra_protocol::{ToolCategory, ToolDefinition, ToolSafetyLevel}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +pub struct SearchReplaceTool { + root: PathBuf, +} + +impl SearchReplaceTool { + pub fn new(workspace: &str) -> Self { + Self { + root: PathBuf::from(workspace), + } + } + + fn resolve_safe(&self, rel: &str) -> Result { + let joined = self.root.join(rel); + if !joined.starts_with(&self.root) { + return Err("Path traversal not allowed".to_string()); + } + Ok(joined) + } +} + +#[async_trait] +impl DynTool for SearchReplaceTool { + fn name(&self) -> &str { + "fs.search_replace" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "fs.search_replace".to_string(), + display_name: "Search and replace in file".to_string(), + description: "Edit a file by searching for text and replacing it. Supports exact match and regex patterns.".to_string(), + category: ToolCategory::Filesystem, + safety_level: ToolSafetyLevel::WorkspaceWrite, + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "search": { "type": "string" }, + "replace": { "type": "string" }, + "regex": { "type": "boolean", "default": false } + }, + "required": ["path", "search", "replace"] + }), + } + } + + async fn execute(&self, args: &Value) -> Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' argument")?; + let search = args + .get("search") + .and_then(|v| v.as_str()) + .ok_or("Missing 'search' argument")?; + let replace = args + .get("replace") + .and_then(|v| v.as_str()) + .ok_or("Missing 'replace' argument")?; + let use_regex = args + .get("regex") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let full_path = self.resolve_safe(path)?; + + let content = std::fs::read_to_string(&full_path) + .map_err(|e| format!("Failed to read {}: {}", path, e))?; + + let new_content = if use_regex { + let re = regex::Regex::new(search) + .map_err(|e| format!("Invalid regex pattern: {}", e))?; + re.replace_all(&content, replace).to_string() + } else { + content.replace(search, replace) + }; + + if content == new_content { + return Ok(format!("No matches found for '{}' in {}", search, path)); + } + + let matches = content.matches(search).count(); + + std::fs::write(&full_path, &new_content) + .map_err(|e| format!("Failed to write {}: {}", path, e))?; + + let parent = full_path.parent().unwrap_or(&self.root); + std::fs::create_dir_all(parent).ok(); + + Ok(format!( + "Replaced {} occurrence(s) in {}", + matches, path + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn search_replace_basic() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "hello world").unwrap(); + + let tool = SearchReplaceTool::new(dir.path().to_str().unwrap()); + let args = json!({ + "path": "test.txt", + "search": "world", + "replace": "rust" + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(tool.execute(&args)).unwrap(); + assert!(result.contains("1 occurrence(s)")); + + let content = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "hello rust"); + } + + #[test] + fn search_replace_regex() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "foo123bar456").unwrap(); + + let tool = SearchReplaceTool::new(dir.path().to_str().unwrap()); + let args = json!({ + "path": "test.txt", + "search": r"\d+", + "replace": "X", + "regex": true + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(tool.execute(&args)).unwrap(); + + let content = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "fooXbarX"); + assert!(result.contains("occurrence(s)")); + } + + #[test] + fn search_replace_path_traversal_blocked() { + let dir = TempDir::new().unwrap(); + let tool = SearchReplaceTool::new(dir.path().to_str().unwrap()); + let args = json!({ + "path": "../etc/passwd", + "search": "x", + "replace": "y" + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(tool.execute(&args)); + assert!(result.is_err()); + } +} diff --git a/crates/codra-protocol/src/lib.rs b/crates/codra-protocol/src/lib.rs index bccad51..04db715 100644 --- a/crates/codra-protocol/src/lib.rs +++ b/crates/codra-protocol/src/lib.rs @@ -666,6 +666,7 @@ pub enum ProviderKind { Gemini, Bedrock, Vertex, + Tera, Mock, } diff --git a/docs/context/VISUAL_CONTEXT.md b/docs/context/VISUAL_CONTEXT.md new file mode 100644 index 0000000..f61956f --- /dev/null +++ b/docs/context/VISUAL_CONTEXT.md @@ -0,0 +1,35 @@ +# Codra Visual Context + +Using screenshots of browser states, UI bugs, dashboards, and docs pages as visual context for debugging and understanding. + +## Purpose + +Codra can benefit from visual context when debugging UI issues, understanding dashboard layouts, or analyzing documentation that is primarily visual. Screenshots capture what the user sees — including layout, color, spacing, and visual states that text extraction cannot convey. + +## When Visual Context Helps + +- **UI bug reproduction** — capture the buggy state for analysis +- **Dashboard understanding** — read data from visual dashboards +- **Documentation with diagrams** — architecture diagrams, flowcharts, network topology +- **Error states** — visual error messages, validation states, toast notifications +- **Before/after comparison** — visual diffs during refactoring +- **Terminal output** — color-coded output, prompt structure, alignment + +## How It Would Work + +1. User captures a screenshot of the current state +2. Screenshot + optional text context sent to AI +3. AI analyzes the visual content +4. Returns structured analysis: visible elements, issues identified, suggestions +5. User can save analysis as project context for future debugging + +## Privacy Rules + +- Capture must be user-triggered +- Do not capture sensitive dashboards without approval +- Screenshots are ephemeral by default +- Save-to-memory stores derived notes, not raw images + +## Status + +This is a planned capability. No implementation yet. The Tera Visual Context layer provides the reference architecture for visual context integration. diff --git a/scripts/test-agent-native-protocol-docs.mjs b/scripts/test-agent-native-protocol-docs.mjs new file mode 100644 index 0000000..fa1f93f --- /dev/null +++ b/scripts/test-agent-native-protocol-docs.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +/** + * Codra Agent-Native Protocol docs tests. + * Run: node scripts/test-agent-native-protocol-docs.mjs + */ + +import * as fs from 'fs' + +let passed = 0 +let failed = 0 + +function assert(condition, label) { + if (condition) { console.log(` ✓ ${label}`); passed++; } + else { console.log(` ✗ ${label}`); failed++; } +} + +console.log('\n=== Codra Agent-Native Protocol Docs Tests ===\n') + +// Test 1: Protocol doc exists +console.log('1. Protocol Doc') +assert(fs.existsSync('apps/code/docs/AGENT_NATIVE_PROTOCOL.md'), 'Protocol doc exists') +const doc = fs.readFileSync('apps/code/docs/AGENT_NATIVE_PROTOCOL.md', 'utf-8') +assert(doc.includes('Talocode Agent-Native Protocol'), 'Names the protocol') +assert(doc.includes('Planned'), 'Marks planned items') + +// Test 2: Actions documented +console.log('\n2. Actions Documented') +assert(doc.includes('codra.plan.create'), 'Plans create action') +assert(doc.includes('codra.plan.run'), 'Plans run action') +assert(doc.includes('codra.file.read'), 'File read action') +assert(doc.includes('codra.file.write'), 'File write action') +assert(doc.includes('codra.git.status'), 'Git status action') +assert(doc.includes('codra.command.run'), 'Command run action') + +// Test 3: Context providers +console.log('\n3. Context Providers') +assert(doc.includes('codra.thread'), 'Thread context provider') +assert(doc.includes('codra.plan'), 'Plan context provider') +assert(doc.includes('codra.files'), 'Files context provider') +assert(doc.includes('codra.git'), 'Git context provider') + +// Test 4: Permission gates +console.log('\n4. Permission Gates') +assert(doc.includes('codra:read'), 'Read permission') +assert(doc.includes('codra:write'), 'Write permission') +assert(doc.includes('codra:execute'), 'Execute permission') + +// Test 5: No external names +console.log('\n5. No External Names') +let noExternal = true +const content = doc.toLowerCase() +if (content.includes('builderio') || content.includes('builder.io') || content.includes('agent-native repo')) { + noExternal = false +} +assert(noExternal, 'No external repo/product names') + +// Test 6: No overclaiming +console.log('\n6. No Overclaiming') +let noOverclaim = true +const overclaimTerms = ['bypass approval', 'uncontrolled agent', 'hidden action', 'fully implemented'] +for (const term of overclaimTerms) { + if (content.includes(term)) { noOverclaim = false } +} +assert(noOverclaim, 'No overclaiming language') + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`) +process.exit(failed > 0 ? 1 : 0) diff --git a/scripts/test-codra-login-html.mjs b/scripts/test-codra-login-html.mjs new file mode 100644 index 0000000..b61f8a3 --- /dev/null +++ b/scripts/test-codra-login-html.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +/** + * Codra login HTML response tests. + * Run: node scripts/test-codra-login-html.mjs + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { console.log(` ✓ ${label}`); passed++; } + else { console.log(` ✗ ${label}`); failed++; } +} + +console.log('\n=== Codra Login HTML Response Tests ===\n'); + +// Test 1: Auth module has safe response parsing +console.log('1. Auth Module Safety'); +const authContent = fs.readFileSync('apps/code/src/auth/index.ts', 'utf-8'); +assert(authContent.includes('readJsonResponse'), 'Has readJsonResponse helper'); +assert(authContent.includes('content-type'), 'Checks content-type header'); +assert(authContent.includes('NON_JSON_RESPONSE'), 'Throws NON_JSON_RESPONSE error'); +assert(authContent.includes(' 0 ? 1 : 0);