Skip to main content

apm_core/
agents.rs

1use std::collections::HashMap;
2use std::path::Path;
3use anyhow::Result;
4use crate::wrapper::{self, Wrapper, WrapperContext, WrapperKind};
5use crate::wrapper::custom::CustomWrapper;
6use crate::config::Config;
7
8pub struct WrapperEntry {
9    pub name: String,
10    pub kind: WrapperKind,
11    pub parser: String,
12    pub configured_as: Vec<String>,
13}
14
15#[derive(Debug)]
16pub struct TestReport {
17    pub exit_code: i32,
18    pub canonical_events: usize,
19    pub non_canonical_lines: usize,
20    pub stderr_lines: usize,
21    pub wall_millis: u64,
22    pub passed: bool,
23}
24
25const MANIFEST_TEMPLATE: &str =
26    "[wrapper]\ncontract_version = 1\nparser = \"canonical\"\n";
27
28const WRAPPER_TEMPLATE: &str = r#"#!/usr/bin/env bash
29# APM wrapper skeleton
30#
31# Environment variables provided by APM:
32#   APM_AGENT_NAME          - name of this worker (from config)
33#   APM_TICKET_ID           - 8-char hex ticket ID
34#   APM_TICKET_BRANCH       - git branch for this ticket
35#   APM_TICKET_WORKTREE     - absolute path to the ticket worktree
36#   APM_SYSTEM_PROMPT_FILE  - path to a file containing the system prompt
37#   APM_USER_MESSAGE_FILE   - path to a file containing the user message (ticket content)
38#   APM_SKIP_PERMISSIONS    - "1" if --dangerously-skip-permissions should be passed; "0" otherwise
39#   APM_PROFILE             - active worker profile name
40#   APM_ROLE_PREFIX         - optional role label prepended to the worker identity
41#   APM_WRAPPER_VERSION     - contract version this APM build implements (currently "1")
42#   APM_BIN                 - absolute path to the running apm binary
43#   APM_OPT_*               - key-value options from [workers.options] in config.toml
44#
45# Contract:
46#   stdout  - emit JSONL events (one JSON object per line, each with a "type" key)
47#   stderr  - free-form log output (not parsed by APM)
48#   exit 0  - success; non-zero signals failure
49#
50set -euo pipefail
51
52# Dump all APM_* env vars to stderr for debugging
53env | grep '^APM_' >&2 || true
54
55# Read inputs
56SYSTEM_PROMPT="$(cat "$APM_SYSTEM_PROMPT_FILE")"
57USER_MESSAGE="$(cat "$APM_USER_MESSAGE_FILE")"
58
59# TODO: replace this printf with a real agent invocation that:
60#   1. Sends SYSTEM_PROMPT + USER_MESSAGE to your AI tool
61#   2. Emits JSONL events on stdout as the tool runs
62printf '{"type":"text","text":"wrapper skeleton -- replace with real invocation"}\n'
63
64# TODO: when the agent finishes, transition the ticket:
65#   apm state "$APM_TICKET_ID" <target-state>
66
67exit 0
68"#;
69
70const CLAUDE_EJECT_SCRIPT: &str = r#"#!/usr/bin/env bash
71# Ejected from APM built-in: claude
72set -euo pipefail
73
74ARGS=(--print --output-format stream-json --verbose)
75
76ARGS+=(--system-prompt "$(cat "$APM_SYSTEM_PROMPT_FILE")")
77
78if [[ -n "${APM_OPT_MODEL:-}" ]]; then
79    ARGS+=(--model "$APM_OPT_MODEL")
80fi
81
82if [[ "${APM_SKIP_PERMISSIONS:-0}" == "1" ]]; then
83    ARGS+=(--dangerously-skip-permissions)
84fi
85
86exec claude "${ARGS[@]}" "$(cat "$APM_USER_MESSAGE_FILE")"
87"#;
88
89const DEFAULT_WORKER_MD: &str = include_str!("default/agents/claude/apm.worker.md");
90const DEFAULT_SPEC_WRITER_MD: &str = include_str!("default/agents/claude/apm.spec-writer.md");
91
92pub fn list_wrappers(root: &Path, config: &Config) -> Result<Vec<WrapperEntry>> {
93    let mut entries: Vec<WrapperEntry> = Vec::new();
94
95    // Built-in entries
96    for name in wrapper::list_builtin_names() {
97        entries.push(WrapperEntry {
98            name: name.to_string(),
99            kind: WrapperKind::Builtin(name.to_string()),
100            parser: "canonical".to_string(),
101            configured_as: vec![],
102        });
103    }
104
105    // Project entries from .apm/agents/
106    let agents_dir = root.join(".apm").join("agents");
107    if agents_dir.is_dir() {
108        let rd = match std::fs::read_dir(&agents_dir) {
109            Ok(rd) => rd,
110            Err(_) => return Ok(entries),
111        };
112        let mut names: Vec<String> = rd
113            .filter_map(|e| e.ok())
114            .filter(|e| e.path().is_dir())
115            .filter_map(|e| e.file_name().into_string().ok())
116            .collect();
117        names.sort();
118
119        for entry_name in names {
120            if let Ok(Some(WrapperKind::Custom { script_path, manifest })) =
121                wrapper::resolve_wrapper(root, &entry_name)
122            {
123                let parser = manifest
124                    .as_ref()
125                    .map(|m| m.parser.clone())
126                    .unwrap_or_else(|| "canonical".to_string());
127                entries.push(WrapperEntry {
128                    name: entry_name,
129                    kind: WrapperKind::Custom { script_path, manifest },
130                    parser,
131                    configured_as: vec![],
132                });
133            }
134        }
135    }
136
137    // Mark which agents are referenced in worker_profile transitions or workers.default.
138    let configured = crate::validate::configured_agent_names(config);
139    for entry in &mut entries {
140        if configured.contains(&entry.name) {
141            entry.configured_as.push("(configured)".to_string());
142        }
143    }
144
145    Ok(entries)
146}
147
148pub fn scaffold_wrapper(root: &Path, name: &str, force: bool) -> Result<()> {
149    let dir = root.join(".apm").join("agents").join(name);
150    if dir.exists() && !force {
151        anyhow::bail!(".apm/agents/{name}/ already exists; use --force to overwrite");
152    }
153    std::fs::create_dir_all(&dir)?;
154
155    // Write wrapper.sh
156    let wrapper_path = dir.join("wrapper.sh");
157    std::fs::write(&wrapper_path, WRAPPER_TEMPLATE)?;
158    #[cfg(unix)]
159    {
160        use std::os::unix::fs::PermissionsExt;
161        std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))?;
162    }
163
164    // Write manifest.toml
165    std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?;
166
167    // Write apm.worker.md
168    let worker_md = std::fs::read_to_string(root.join(".apm").join("apm.worker.md"))
169        .unwrap_or_else(|_| DEFAULT_WORKER_MD.to_string());
170    std::fs::write(dir.join("apm.worker.md"), &worker_md)?;
171
172    // Write apm.spec-writer.md
173    let spec_writer_md =
174        std::fs::read_to_string(root.join(".apm").join("apm.spec-writer.md"))
175            .unwrap_or_else(|_| DEFAULT_SPEC_WRITER_MD.to_string());
176    std::fs::write(dir.join("apm.spec-writer.md"), &spec_writer_md)?;
177
178    Ok(())
179}
180
181pub fn test_wrapper(root: &Path, name: &str) -> Result<TestReport> {
182    let kind = wrapper::resolve_wrapper(root, name)?.ok_or_else(|| {
183        anyhow::anyhow!(
184            "agent '{}' not found: checked built-ins and .apm/agents/{}/",
185            name,
186            name
187        )
188    })?;
189
190    let tmp_dir = tempfile::tempdir()?;
191    let tmp = tmp_dir.path().to_path_buf();
192
193    let sys_file = tmp.join("system.txt");
194    let msg_file = tmp.join("message.txt");
195    let log_path = tmp.join("wrapper.log");
196
197    std::fs::write(&sys_file, "You are a test agent.")?;
198    std::fs::write(&msg_file, "Test run -- apm agents test.")?;
199
200    let ctx = WrapperContext {
201        worker_name: "agents-test".to_string(),
202        agent_type: name.to_string(),
203        ticket_id: "00000000".to_string(),
204        ticket_branch: "test/agents-test".to_string(),
205        worktree_path: tmp.clone(),
206        system_prompt_file: sys_file,
207        user_message_file: msg_file,
208        skip_permissions: false,
209        profile: "test".to_string(),
210        role_prefix: None,
211        options: HashMap::new(),
212        model: None,
213        log_path: log_path.clone(),
214        container: None,
215        extra_env: HashMap::new(),
216        root: root.to_path_buf(),
217        keychain: HashMap::new(),
218        current_state: "test".to_string(),
219            command: None,
220    };
221
222    let start = std::time::Instant::now();
223    let mut child = match kind {
224        WrapperKind::Custom { script_path, manifest } => {
225            CustomWrapper { script_path, manifest }.spawn(&ctx)?
226        }
227        WrapperKind::Builtin(n) => {
228            wrapper::resolve_builtin(&n)
229                .expect("registered builtin")
230                .spawn(&ctx)?
231        }
232    };
233
234    let status = child.wait()?;
235    let wall_millis = start.elapsed().as_millis() as u64;
236    let exit_code = status.code().unwrap_or(-1);
237
238    // Classify log lines
239    let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
240    let mut canonical_events = 0usize;
241    let mut non_canonical_lines = 0usize;
242    let mut stderr_lines = 0usize;
243
244    for line in log_content.lines() {
245        if line.is_empty() {
246            continue;
247        }
248        if line.starts_with("APM_") {
249            stderr_lines += 1;
250        } else if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
251            if val.get("type").is_some() {
252                canonical_events += 1;
253            } else {
254                non_canonical_lines += 1;
255            }
256        } else {
257            non_canonical_lines += 1;
258        }
259    }
260
261    let passed = status.success() && canonical_events >= 1;
262    let report = TestReport {
263        exit_code,
264        canonical_events,
265        non_canonical_lines,
266        stderr_lines,
267        wall_millis,
268        passed,
269    };
270
271    Ok(report)
272}
273
274pub fn eject_wrapper(root: &Path, name: &str) -> Result<()> {
275    if wrapper::resolve_builtin(name).is_none() {
276        anyhow::bail!(
277            "'{}' is not a known built-in; run apm agents list to see available wrappers",
278            name
279        );
280    }
281
282    let dir = root.join(".apm").join("agents").join(name);
283    if dir.exists() {
284        anyhow::bail!(".apm/agents/{name}/ already exists; delete it first to eject again");
285    }
286
287    std::fs::create_dir_all(&dir)?;
288
289    let script_content = match name {
290        "claude" => CLAUDE_EJECT_SCRIPT,
291        other => anyhow::bail!("eject not yet implemented for built-in {}", other),
292    };
293    let script_path = dir.join("wrapper.sh");
294    std::fs::write(&script_path, script_content)?;
295    #[cfg(unix)]
296    {
297        use std::os::unix::fs::PermissionsExt;
298        std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
299    }
300
301    // Write manifest.toml — intentionally the same template as scaffold_wrapper:
302    // recognised as v1-canonical by 2c32a282's manifest parser and 2e772eab's version check,
303    // so the ejected script requires no extra setup.
304    std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?;
305
306    Ok(())
307}