codewhale-tui 0.9.8

Terminal UI for open-source and open-weight coding models
Documentation
//! Model-facing controller for Codewhale's continual RLM harness.

use async_trait::async_trait;
use serde_json::{Value, json};

use crate::continual_harness::{HarnessEntryKind, HarnessRefinement, overview, refine, remove};

use super::spec::{
    ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
};

/// Persistent, bounded harness state for context-aware, long-running work.
pub struct HarnessTool;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HarnessAction {
    Overview,
    Refine,
    Remove,
}

fn parse_action(input: &Value) -> Result<HarnessAction, ToolError> {
    let action = input
        .get("action")
        .and_then(Value::as_str)
        .unwrap_or("overview")
        .trim()
        .to_ascii_lowercase();
    match action.as_str() {
        "" | "overview" | "list" | "status" => Ok(HarnessAction::Overview),
        "refine" | "learn" => Ok(HarnessAction::Refine),
        "remove" | "forget" | "delete" => Ok(HarnessAction::Remove),
        _ => Err(ToolError::invalid_input(
            "harness action must be overview, refine, or remove",
        )),
    }
}

fn parse_kind(input: &Value) -> Result<HarnessEntryKind, ToolError> {
    match required_str(input, "kind")?.trim() {
        "prompt_note" => Ok(HarnessEntryKind::PromptNote),
        "subagent_spec" => Ok(HarnessEntryKind::SubagentSpec),
        "skill_hint" => Ok(HarnessEntryKind::SkillHint),
        other => Err(ToolError::invalid_input(format!(
            "harness kind `{other}` must be prompt_note, subagent_spec, or skill_hint"
        ))),
    }
}

#[async_trait]
impl ToolSpec for HarnessTool {
    fn name(&self) -> &'static str {
        "harness"
    }

    fn description(&self) -> &'static str {
        "Inspect or refine the durable continual harness for this workspace. Use action=overview at the start of substantial multi-turn work to recover bounded prompt notes, reusable sub-agent briefs, and skill hints. Use action=refine only after observing concrete evidence for a reusable improvement; it stores a small project-local entry that later turns receive as untrusted working guidance. Use action=remove to retire an obsolete entry. Keep large source material in rlm, compose parallel child work with workflow task(...), and use agent action=message/followup for child coordination."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["overview", "refine", "remove"],
                    "description": "overview (default) lists harness state; refine writes one evidence-backed improvement; remove retires one exact id."
                },
                "kind": {
                    "type": "string",
                    "enum": ["prompt_note", "subagent_spec", "skill_hint"],
                    "description": "Required for refine: the kind of reusable improvement."
                },
                "title": {
                    "type": "string",
                    "description": "Required for refine: compact title for the improvement."
                },
                "content": {
                    "type": "string",
                    "description": "Required for refine: bounded, reusable guidance rather than a transcript or temporary scratch."
                },
                "evidence": {
                    "type": "string",
                    "description": "Required for refine: the concrete observation that justified retaining this guidance."
                },
                "id": {
                    "type": "string",
                    "description": "Required for remove: exact entry id returned by overview."
                }
            },
            "additionalProperties": false
        })
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WritesFiles]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Required
    }

    fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
        match parse_action(input) {
            Ok(HarnessAction::Overview) => ApprovalRequirement::Auto,
            _ => ApprovalRequirement::Required,
        }
    }

    fn is_read_only_for(&self, input: &Value) -> bool {
        matches!(parse_action(input), Ok(HarnessAction::Overview))
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        match parse_action(&input)? {
            HarnessAction::Overview => {
                let state = overview(&context.workspace).map_err(|error| {
                    ToolError::execution_failed(format!("harness overview failed: {error}"))
                })?;
                ToolResult::json(&json!({
                    "path": state.path,
                    "entries": state.entries,
                    "runtime": {
                        "context": "rlm keeps large context as a persistent Python variable",
                        "orchestration": "workflow composes task(...) calls and parallel fan-out",
                        "messaging": "agent action=message and action=followup coordinate active children",
                        "continuation": "goal keeps an explicit durable objective active"
                    }
                }))
                .map_err(|error| ToolError::execution_failed(error.to_string()))
            }
            HarnessAction::Refine => {
                let entry = refine(
                    &context.workspace,
                    HarnessRefinement {
                        kind: parse_kind(&input)?,
                        title: required_str(&input, "title")?.to_string(),
                        content: required_str(&input, "content")?.to_string(),
                        evidence: required_str(&input, "evidence")?.to_string(),
                    },
                )
                .map_err(|error| {
                    ToolError::execution_failed(format!("harness refinement failed: {error}"))
                })?;
                ToolResult::json(&json!({
                    "refined": entry,
                    "receipt": "Stored as project-local supplemental guidance for later turns."
                }))
                .map_err(|error| ToolError::execution_failed(error.to_string()))
            }
            HarnessAction::Remove => {
                let entry =
                    remove(&context.workspace, required_str(&input, "id")?).map_err(|error| {
                        ToolError::execution_failed(format!("harness removal failed: {error}"))
                    })?;
                ToolResult::json(&json!({"removed": entry}))
                    .map_err(|error| ToolError::execution_failed(error.to_string()))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[tokio::test]
    async fn overview_is_read_only_and_refinement_round_trips() {
        let tmp = tempdir().expect("tempdir");
        let context = ToolContext::new(tmp.path());
        let tool = HarnessTool;

        assert!(tool.is_read_only_for(&json!({"action": "overview"})));
        assert_eq!(
            tool.approval_requirement_for(&json!({"action": "overview"})),
            ApprovalRequirement::Auto
        );

        let created = tool
            .execute(
                json!({
                    "action": "refine",
                    "kind": "prompt_note",
                    "title": "Preserve exact release evidence",
                    "content": "Keep literal test result lines with each release claim.",
                    "evidence": "A prior release handoff mixed stale CI evidence with current local output."
                }),
                &context,
            )
            .await
            .expect("refinement result");
        let created_json: Value = serde_json::from_str(&created.content).expect("json receipt");
        let id = created_json["refined"]["id"].as_str().expect("entry id");

        let overview = tool
            .execute(json!({"action": "overview"}), &context)
            .await
            .expect("overview result");
        assert!(overview.content.contains(id));
        assert!(overview.content.contains("workflow composes"));
    }

    #[tokio::test]
    async fn refine_rejects_missing_evidence() {
        let tmp = tempdir().expect("tempdir");
        let context = ToolContext::new(tmp.path());
        let error = HarnessTool
            .execute(
                json!({
                    "action": "refine",
                    "kind": "skill_hint",
                    "title": "Use a skill",
                    "content": "Try the relevant skill."
                }),
                &context,
            )
            .await
            .expect_err("evidence must be required");
        assert!(error.to_string().contains("evidence"));
    }
}