返回 CodeWhale
harness.rs
根目录 / crates / tui / src / tools / harness.rs
1 //! Model-facing controller for Codewhale's continual RLM harness.
2
3 use async_trait::async_trait;
4 use serde_json::{Value, json};
5
6 use crate::continual_harness::{HarnessEntryKind, HarnessRefinement, overview, refine, remove};
7
8 use super::spec::{
9 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
10 };
11
12 /// Persistent, bounded harness state for context-aware, long-running work.
13 pub struct HarnessTool;
14
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 enum HarnessAction {
17 Overview,
18 Refine,
19 Remove,
20 }
21
22 fn parse_action(input: &Value) -> Result<HarnessAction, ToolError> {
23 let action = input
24 .get("action")
25 .and_then(Value::as_str)
26 .unwrap_or("overview")
27 .trim()
28 .to_ascii_lowercase();
29 match action.as_str() {
30 "" | "overview" | "list" | "status" => Ok(HarnessAction::Overview),
31 "refine" | "learn" => Ok(HarnessAction::Refine),
32 "remove" | "forget" | "delete" => Ok(HarnessAction::Remove),
33 _ => Err(ToolError::invalid_input(
34 "harness action must be overview, refine, or remove",
35 )),
36 }
37 }
38
39 fn parse_kind(input: &Value) -> Result<HarnessEntryKind, ToolError> {
40 match required_str(input, "kind")?.trim() {
41 "prompt_note" => Ok(HarnessEntryKind::PromptNote),
42 "subagent_spec" => Ok(HarnessEntryKind::SubagentSpec),
43 "skill_hint" => Ok(HarnessEntryKind::SkillHint),
44 other => Err(ToolError::invalid_input(format!(
45 "harness kind `{other}` must be prompt_note, subagent_spec, or skill_hint"
46 ))),
47 }
48 }
49
50 #[async_trait]
51 impl ToolSpec for HarnessTool {
52 fn name(&self) -> &'static str {
53 "harness"
54 }
55
56 fn description(&self) -> &'static str {
57 "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."
58 }
59
60 fn input_schema(&self) -> Value {
61 json!({
62 "type": "object",
63 "properties": {
64 "action": {
65 "type": "string",
66 "enum": ["overview", "refine", "remove"],
67 "description": "overview (default) lists harness state; refine writes one evidence-backed improvement; remove retires one exact id."
68 },
69 "kind": {
70 "type": "string",
71 "enum": ["prompt_note", "subagent_spec", "skill_hint"],
72 "description": "Required for refine: the kind of reusable improvement."
73 },
74 "title": {
75 "type": "string",
76 "description": "Required for refine: compact title for the improvement."
77 },
78 "content": {
79 "type": "string",
80 "description": "Required for refine: bounded, reusable guidance rather than a transcript or temporary scratch."
81 },
82 "evidence": {
83 "type": "string",
84 "description": "Required for refine: the concrete observation that justified retaining this guidance."
85 },
86 "id": {
87 "type": "string",
88 "description": "Required for remove: exact entry id returned by overview."
89 }
90 },
91 "additionalProperties": false
92 })
93 }
94
95 fn capabilities(&self) -> Vec<ToolCapability> {
96 vec![ToolCapability::WritesFiles]
97 }
98
99 fn approval_requirement(&self) -> ApprovalRequirement {
100 ApprovalRequirement::Required
101 }
102
103 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
104 match parse_action(input) {
105 Ok(HarnessAction::Overview) => ApprovalRequirement::Auto,
106 _ => ApprovalRequirement::Required,
107 }
108 }
109
110 fn is_read_only_for(&self, input: &Value) -> bool {
111 matches!(parse_action(input), Ok(HarnessAction::Overview))
112 }
113
114 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
115 match parse_action(&input)? {
116 HarnessAction::Overview => {
117 let state = overview(&context.workspace).map_err(|error| {
118 ToolError::execution_failed(format!("harness overview failed: {error}"))
119 })?;
120 ToolResult::json(&json!({
121 "path": state.path,
122 "entries": state.entries,
123 "runtime": {
124 "context": "rlm keeps large context as a persistent Python variable",
125 "orchestration": "workflow composes task(...) calls and parallel fan-out",
126 "messaging": "agent action=message and action=followup coordinate active children",
127 "continuation": "goal keeps an explicit durable objective active"
128 }
129 }))
130 .map_err(|error| ToolError::execution_failed(error.to_string()))
131 }
132 HarnessAction::Refine => {
133 let entry = refine(
134 &context.workspace,
135 HarnessRefinement {
136 kind: parse_kind(&input)?,
137 title: required_str(&input, "title")?.to_string(),
138 content: required_str(&input, "content")?.to_string(),
139 evidence: required_str(&input, "evidence")?.to_string(),
140 },
141 )
142 .map_err(|error| {
143 ToolError::execution_failed(format!("harness refinement failed: {error}"))
144 })?;
145 ToolResult::json(&json!({
146 "refined": entry,
147 "receipt": "Stored as project-local supplemental guidance for later turns."
148 }))
149 .map_err(|error| ToolError::execution_failed(error.to_string()))
150 }
151 HarnessAction::Remove => {
152 let entry =
153 remove(&context.workspace, required_str(&input, "id")?).map_err(|error| {
154 ToolError::execution_failed(format!("harness removal failed: {error}"))
155 })?;
156 ToolResult::json(&json!({"removed": entry}))
157 .map_err(|error| ToolError::execution_failed(error.to_string()))
158 }
159 }
160 }
161 }
162
163 #[cfg(test)]
164 mod tests {
165 use super::*;
166 use tempfile::tempdir;
167
168 #[tokio::test]
169 async fn overview_is_read_only_and_refinement_round_trips() {
170 let tmp = tempdir().expect("tempdir");
171 let context = ToolContext::new(tmp.path());
172 let tool = HarnessTool;
173
174 assert!(tool.is_read_only_for(&json!({"action": "overview"})));
175 assert_eq!(
176 tool.approval_requirement_for(&json!({"action": "overview"})),
177 ApprovalRequirement::Auto
178 );
179
180 let created = tool
181 .execute(
182 json!({
183 "action": "refine",
184 "kind": "prompt_note",
185 "title": "Preserve exact release evidence",
186 "content": "Keep literal test result lines with each release claim.",
187 "evidence": "A prior release handoff mixed stale CI evidence with current local output."
188 }),
189 &context,
190 )
191 .await
192 .expect("refinement result");
193 let created_json: Value = serde_json::from_str(&created.content).expect("json receipt");
194 let id = created_json["refined"]["id"].as_str().expect("entry id");
195
196 let overview = tool
197 .execute(json!({"action": "overview"}), &context)
198 .await
199 .expect("overview result");
200 assert!(overview.content.contains(id));
201 assert!(overview.content.contains("workflow composes"));
202 }
203
204 #[tokio::test]
205 async fn refine_rejects_missing_evidence() {
206 let tmp = tempdir().expect("tempdir");
207 let context = ToolContext::new(tmp.path());
208 let error = HarnessTool
209 .execute(
210 json!({
211 "action": "refine",
212 "kind": "skill_hint",
213 "title": "Use a skill",
214 "content": "Try the relevant skill."
215 }),
216 &context,
217 )
218 .await
219 .expect_err("evidence must be required");
220 assert!(error.to_string().contains("evidence"));
221 }
222 }
223
223 lines RUST