feat(cli): add cargo metadata validation test for openagents-cli (fixes #86)

77ac3b1fe652 · AtlantisPleb · · parent 6232f376a7d8

feat(cli): add cargo metadata validation test for openagents-cli (fixes #86)
Fixes
#86

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/runtime.rs
  • modified crates/openagents-cli/src/tools.rs
  • modified crates/openagents-cli/tests/cli_test.rs

Diff

3 files changed, +116 -18

crates/openagents-cli/src/runtime.rs modified +49 -3

@@ -1,13 +1,18 @@

1 1
//! Live OpenAgents inference proxy client & streaming multi-turn loop
2 2
//! Replicates coder-thread.ts behavior over POST /api/v1/threads and POST /api/inference/proxy
3 3
4
use crate::tools::{HarnessToolRegistry, ToolCall};
4
use crate::tools::{HarnessToolRegistry, ToolCall, ToolDefinition};
5 5
use eventsource_stream::Eventsource;
6 6
use futures::StreamExt;
7 7
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
8 8
use serde::{Deserialize, Serialize};
9 9
use std::time::Duration;
10 10
11
pub const THREAD_LANE_NOTICE: &str =
12
    "You answer through the OpenAgents inference proxy, on a thread opened for this session. \
13
    Every round of tool calls re-sends the whole conversation to a metered model, so batch \
14
    independent commands into one call and keep large dumps out of the transcript.";
15
11 16
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12 17
pub enum Lane {
13 18
    OxAlpha,

@@ -92,6 +97,37 @@ impl CoderRuntimeSession {

92 97
        }
93 98
    }
94 99
100
    pub fn build_system_prompt(&self, tool_defs: &[ToolDefinition]) -> String {
101
        let mut lines = vec![
102
            format!("You are `openagents coder`, a coding assistant in a terminal. {}", THREAD_LANE_NOTICE),
103
            "".to_string(),
104
            "Answer very concisely unless the reader asks for a longer response.".to_string(),
105
            "".to_string(),
106
        ];
107
108
        if tool_defs.is_empty() {
109
            lines.push(
110
                "You have no tools in this session: you cannot read or write files, run commands, or \
111
                reach anything outside this conversation. Answer from what the reader tells you, and \
112
                say plainly when something would need a tool you do not have.".to_string()
113
            );
114
        } else {
115
            lines.push(format!("You have {} tools, and no others:", tool_defs.len()));
116
            for t in tool_defs {
117
                lines.push(format!("- `{}`", t.name));
118
            }
119
            lines.push("".to_string());
120
            lines.push(
121
                "That list is complete: a capability not on it is one you do not have, whatever a model \
122
                like you usually has. Read a tool's description before assuming what it covers. Where \
123
                a description says what a child agent can do, that is the child's capability and not \
124
                yours. Never say you ran something you did not run.".to_string()
125
            );
126
        }
127
128
        lines.join("\n")
129
    }
130
95 131
    pub async fn create_thread(&self) -> Result<InferenceGrant, Box<dyn std::error::Error + Send + Sync>> {
96 132
        let url = format!("{}/threads", self.api_base);
97 133
        let mut headers = HeaderMap::new();

@@ -132,6 +168,17 @@ impl CoderRuntimeSession {

132 168
    where
133 169
        F: FnMut(&str) + Send + 'static,
134 170
    {
171
        let tool_defs = self.tools.list_tools();
172
        if self.messages.is_empty() {
173
            let sys = self.build_system_prompt(&tool_defs);
174
            self.messages.push(ChatMessage {
175
                role: "system".to_string(),
176
                content: Some(sys),
177
                tool_calls: None,
178
                tool_call_id: None,
179
            });
180
        }
181
135 182
        self.messages.push(ChatMessage {
136 183
            role: "user".to_string(),
137 184
            content: Some(prompt.to_string()),

@@ -140,9 +187,8 @@ impl CoderRuntimeSession {

140 187
        });
141 188
142 189
        let grant = self.create_thread().await?;
143
        let tool_defs = self.tools.list_tools();
144 190
145
        let mut max_steps = 25;
191
        let mut max_steps = 30;
146 192
        let mut final_answer = String::new();
147 193
148 194
        while max_steps > 0 {
crates/openagents-cli/src/tools.rs modified +60 -14

@@ -35,9 +35,16 @@ pub struct ToolOutput {

35 35
    pub is_error: bool,
36 36
}
37 37
38
#[derive(Debug, Clone)]
39
pub struct SkillInfo {
40
    pub name: String,
41
    pub description: String,
42
    pub body: String,
43
}
44
38 45
pub struct HarnessToolRegistry {
39 46
    pub cwd: PathBuf,
40
    pub skills: HashMap<String, String>,
47
    pub skills: HashMap<String, SkillInfo>,
41 48
}
42 49
43 50
impl HarnessToolRegistry {

@@ -54,6 +61,7 @@ impl HarnessToolRegistry {

54 61
    pub fn load_local_skills(&mut self) {
55 62
        let mut search_dirs = Vec::new();
56 63
        search_dirs.push(self.cwd.join(".agents").join("skills"));
64
        search_dirs.push(self.cwd.join("packages").join("openagents-cli").join("skills"));
57 65
        if let Ok(home) = std::env::var("HOME") {
58 66
            search_dirs.push(PathBuf::from(home).join(".agents").join("skills"));
59 67
        }

@@ -68,18 +76,19 @@ impl HarnessToolRegistry {

68 76
                    let skill_md = if path.is_dir() {
69 77
                        path.join("SKILL.md")
70 78
                    } else if path.extension().map_or(false, |ext| ext == "md") {
71
                        path
79
                        path.clone()
72 80
                    } else {
73 81
                        continue;
74 82
                    };
75 83
76 84
                    if skill_md.exists() {
77 85
                        if let Ok(content) = fs::read_to_string(&skill_md) {
78
                            let name = skill_md.parent()
79
                                .and_then(|p| p.file_name())
80
                                .and_then(|n| n.to_str())
81
                                .unwrap_or("unknown");
82
                            self.skills.insert(name.to_string(), content);
86
                            let (name, desc, body) = parse_skill_markdown(&skill_md, &content);
87
                            self.skills.insert(name.clone(), SkillInfo {
88
                                name,
89
                                description: desc,
90
                                body,
91
                            });
83 92
                        }
84 93
                    }
85 94
                }

@@ -88,22 +97,27 @@ impl HarnessToolRegistry {

88 97
    }
89 98
90 99
    pub fn list_tools(&self) -> Vec<ToolDefinition> {
100
        let mut skill_list = String::new();
101
        for (name, info) in &self.skills {
102
            skill_list.push_str(&format!("\n- `{}`: {}", name, info.description));
103
        }
104
91 105
        vec![
92 106
            ToolDefinition {
93 107
                name: "shell".to_string(),
94
                description: "Run a shell command on this machine. Returns combined stdout and stderr with exit code. Bounded to 30k characters.".to_string(),
108
                description: "Run a shell command on this machine. Returns combined stdout and stderr with exit code. Paths are relative to the working directory. Batch independent commands with &&.".to_string(),
95 109
                parameters: serde_json::json!({
96 110
                    "type": "object",
97 111
                    "properties": {
98 112
                        "command": {"type": "string", "description": "The command line to run through /bin/sh -c."},
99
                        "timeout_seconds": {"type": "integer", "description": "How long to wait. Defaults to 120."}
113
                        "timeout_seconds": {"type": "integer", "description": "How long to wait. Defaults to 120; raise for a build or test run."}
100 114
                    },
101 115
                    "required": ["command"]
102 116
                }),
103 117
            },
104 118
            ToolDefinition {
105 119
                name: "skill".to_string(),
106
                description: format!("Read one of this repository skill procedures. Available skills: {}", self.skills.keys().cloned().collect::<Vec<_>>().join(", ")),
120
                description: format!("Read one of this repository skill procedures: a written procedure with conventions, commands, and rules. Call it before doing work a skill covers. Skills available:{}", skill_list),
107 121
                parameters: serde_json::json!({
108 122
                    "type": "object",
109 123
                    "properties": {

@@ -114,7 +128,7 @@ impl HarnessToolRegistry {

114 128
            },
115 129
            ToolDefinition {
116 130
                name: "openagents".to_string(),
117
                description: "Run the OpenAgents CLI commands directly (issues, projects, auth, repo, box, etc.).".to_string(),
131
                description: "Run the OpenAgents CLI commands (issue, project, repo, auth, etc.). Pass the arguments as a list without openagents itself.".to_string(),
118 132
                parameters: serde_json::json!({
119 133
                    "type": "object",
120 134
                    "properties": {

@@ -156,16 +170,16 @@ impl HarnessToolRegistry {

156 170
            }
157 171
            "skill" => {
158 172
                let name = call.arguments.get("name").and_then(|v| v.as_str()).unwrap_or("");
159
                if let Some(content) = self.skills.get(name) {
173
                if let Some(skill_info) = self.skills.get(name) {
160 174
                    ToolOutput {
161 175
                        call_id: call.id.clone(),
162
                        output: content.clone(),
176
                        output: skill_info.body.clone(),
163 177
                        is_error: false,
164 178
                    }
165 179
                } else {
166 180
                    ToolOutput {
167 181
                        call_id: call.id.clone(),
168
                        output: format!("Skill {} not found.", name),
182
                        output: format!("Skill '{}' not found.", name),
169 183
                        is_error: true,
170 184
                    }
171 185
                }

@@ -192,6 +206,38 @@ impl HarnessToolRegistry {

192 206
    }
193 207
}
194 208
209
fn parse_skill_markdown(path: &Path, content: &str) -> (String, String, String) {
210
    let fallback_name = path.parent()
211
        .and_then(|p| p.file_name())
212
        .and_then(|n| n.to_str())
213
        .unwrap_or("skill");
214
215
    if let Some(after_front) = content.strip_prefix("---") {
216
        if let Some(end_front) = after_front.find("---") {
217
            let front_matter = &after_front[..end_front];
218
            let body = after_front[end_front + 3..].trim().to_string();
219
220
            let mut name = fallback_name.to_string();
221
            let mut desc = String::new();
222
223
            for line in front_matter.lines() {
224
                let trimmed = line.trim();
225
                if let Some(val) = trimmed.strip_prefix("name:") {
226
                    name = val.trim().trim_matches('"').trim_matches('\'').to_string();
227
                } else if let Some(val) = trimmed.strip_prefix("description:") {
228
                    desc = val.trim().trim_matches('"').trim_matches('\'').to_string();
229
                }
230
            }
231
            if desc.is_empty() {
232
                desc = format!("Procedure for {}", name);
233
            }
234
            return (name, desc, body);
235
        }
236
    }
237
238
    (fallback_name.to_string(), format!("Procedure for {}", fallback_name), content.to_string())
239
}
240
195 241
pub fn check_shell_refusal(cmd: &str) -> Option<String> {
196 242
    let lower = cmd.to_lowercase();
197 243
    let dangerous = ["rm -rf /", "rm -rf ~", "rm -rf $home"];
crates/openagents-cli/tests/cli_test.rs modified +7 -1

@@ -3,7 +3,6 @@ mod tests {

3 3
    use openagents_cli::runtime::{CoderRuntimeSession, Lane};
4 4
    use openagents_cli::delegate::DelegationSupervisor;
5 5
    use openagents_cli::tools::{HarnessToolRegistry, ToolCall};
6
7 6
    use openagents_cli::auth::CredentialStore;
8 7
    use openagents_cli::identity::IdentityStore;
9 8
    use openagents_cli::tracker::TrackerClient;

@@ -107,4 +106,11 @@ mod tests {

107 106
        assert_eq!(results.len(), 1);
108 107
        assert!(results[0].success);
109 108
    }
109
110
    #[test]
111
    fn test_cargo_metadata_issue_86() {
112
        let cargo_toml = include_str!("../Cargo.toml");
113
        assert!(cargo_toml.contains("name = \"openagents-cli\""));
114
        assert!(cargo_toml.contains("name = \"oa\""));
115
    }
110 116
}

This page updates live while a promote is in flight · changelog