Skip to main content

agent_harness_adapter_core/harness/
adapter.rs

1//! The contract every harness implements, and the built-in harnesses.
2// @zen-component: KIT-Adapter
3
4use std::{
5    fmt::Debug,
6    path::{Path, PathBuf},
7    sync::Arc,
8};
9
10use crate::{
11    Result,
12    harness::{Part, PartResult, Scope},
13    hook::{Answer, Event, HookInput, Output, ToolCall, claude_names},
14    integration::{Integration, Item},
15};
16
17/// An agent harness: where it reads a tool's integration and how it talks
18/// to hooks. The library's own harnesses are in [`builtin`]; a tool may add its
19/// own, and [`install`](crate::harness::install), [`status`](crate::harness::status)
20/// and the hook functions work with it unchanged.
21// @zen-impl: KIT-18_AC-1
22// @zen-impl: KIT-18_AC-3
23pub trait Harness: Debug + Send + Sync {
24    /// The harness's id, e.g. `claude`: the name users give, and `{harness}`
25    /// in a hook command.
26    fn id(&self) -> &str;
27
28    /// The scopes it has files at.
29    fn scopes(&self) -> &[Scope];
30
31    /// The parts for `integration`'s items at `cx`, at most one per item,
32    /// each named by its item ([`Item::as_str`]). An item the harness
33    /// cannot take at the scope has no part.
34    // @zen-impl: KIT-18_AC-4
35    fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>>;
36
37    /// The locations it loads for `item` at `cx`: those it always loads
38    /// (which must include where it renders the item) and those it may
39    /// load.
40    fn reads(&self, item: Item, cx: &Context) -> Result<Reads>;
41
42    /// The hook events it runs commands for: every [`Event`] unless it says
43    /// otherwise.
44    fn hook_events(&self) -> &[Event] {
45        &Event::ALL
46    }
47
48    /// `text`, the input it sent a hook for `event`, as a [`HookInput`].
49    fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput>;
50
51    /// `answer` to `event` in its form; [`Error::Unsupported`](crate::Error::Unsupported)
52    /// when it cannot express it.
53    fn answer(&self, event: Event, answer: &Answer) -> Result<Output>;
54
55    /// What the user still has to do after an install, from its parts'
56    /// results, e.g. trust the folder.
57    // @zen-impl: KIT-20_AC-1
58    fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
59        let _ = (cx, parts);
60        Vec::new()
61    }
62
63    /// The program that runs the harness, e.g. `claude`, looked for on
64    /// `PATH` to tell whether the user has it; `None` when it has none.
65    // @zen-impl: KIT-18_AC-1
66    fn program(&self) -> Option<&str> {
67        None
68    }
69
70    /// `call`, one of its tool calls, as Claude Code would name it and give
71    /// its input (KIT-11_AC-9). By default, named by its kind.
72    fn claude_tool(&self, call: &ToolCall) -> ToolCall {
73        claude_names::by_kind(call)
74    }
75}
76
77/// What a harness renders for and reads at: the tool, the scope and its
78/// root, and the user's home.
79#[derive(Debug, Clone, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct Context {
82    /// The tool's name.
83    pub tool: String,
84    /// The scope.
85    pub scope: Scope,
86    /// The scope's root: the project, or the home directory.
87    pub root: PathBuf,
88    /// The home directory, when known (a project setting may live there).
89    pub user_root: Option<PathBuf>,
90}
91
92impl Context {
93    /// A context.
94    pub fn new(
95        tool: impl Into<String>,
96        scope: Scope,
97        root: impl Into<PathBuf>,
98        user_root: Option<PathBuf>,
99    ) -> Self {
100        Context {
101            tool: tool.into(),
102            scope,
103            root: root.into(),
104            user_root,
105        }
106    }
107
108    /// `rel` (relative to the root, `/`-separated) as a path.
109    pub fn path(&self, rel: &str) -> PathBuf {
110        self.root.join(rel)
111    }
112
113    /// `path` relative to the root, `/`-separated, when it is under it (and
114    /// not the root itself).
115    pub fn relative(&self, path: &Path) -> Option<String> {
116        let abs = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
117        let (path, root) = (abs(path), abs(&self.root));
118        let rel = path
119            .strip_prefix(&root)
120            .ok()
121            .map(Path::to_path_buf)
122            .or_else(|| {
123                let (p, r) = (path.canonicalize().ok()?, root.canonicalize().ok()?);
124                p.strip_prefix(r).ok().map(Path::to_path_buf)
125            })?;
126        let parts: Vec<String> = rel
127            .components()
128            .map(|c| c.as_os_str().to_string_lossy().into_owned())
129            .collect();
130        (!parts.is_empty()).then(|| parts.join("/"))
131    }
132
133    /// Whether `rel` is a file under the root.
134    pub fn is_file(&self, rel: &str) -> bool {
135        self.path(rel).is_file()
136    }
137
138    /// The home directory joined with `rel`, when the home is known.
139    pub fn user_path(&self, rel: &str) -> Option<PathBuf> {
140        self.user_root.as_deref().map(|h: &Path| h.join(rel))
141    }
142}
143
144/// The locations a harness loads for an item, relative to the root.
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct Reads {
148    /// Loaded whenever they exist.
149    pub always: Vec<String>,
150    /// Loaded in some setups (a setting, one of the harness's front ends).
151    pub maybe: Vec<String>,
152}
153
154impl Reads {
155    /// Always loads `locations`.
156    pub fn always<I: IntoIterator<Item = S>, S: Into<String>>(locations: I) -> Self {
157        Reads {
158            always: locations.into_iter().map(Into::into).collect(),
159            maybe: Vec::new(),
160        }
161    }
162
163    /// Also may load `locations`.
164    #[must_use]
165    pub fn maybe<I: IntoIterator<Item = S>, S: Into<String>>(mut self, locations: I) -> Self {
166        self.maybe.extend(locations.into_iter().map(Into::into));
167        self
168    }
169
170    /// Whether `location` is always loaded.
171    pub fn loads(&self, location: &str) -> bool {
172        self.always.iter().any(|l| l == location)
173    }
174
175    /// Whether `location` is loaded, always or maybe.
176    pub fn may_load(&self, location: &str) -> bool {
177        self.loads(location) || self.maybe.iter().any(|l| l == location)
178    }
179}
180
181/// Every built-in harness, in a stable order: the most specific first, the
182/// generic `agents` last, so a shared location is written by a harness of
183/// its own.
184// @zen-impl: KIT-18_AC-2
185pub fn builtin() -> Vec<Arc<dyn Harness>> {
186    vec![
187        Arc::new(crate::claude::Claude),
188        Arc::new(crate::codex::Codex),
189        Arc::new(crate::factory::Factory),
190        Arc::new(crate::gemini::Gemini),
191        Arc::new(crate::copilot::Copilot),
192        Arc::new(crate::cursor::Cursor),
193        Arc::new(crate::pi::Pi),
194        Arc::new(crate::opencode::OpenCode),
195        Arc::new(crate::kilo::Kilo),
196        Arc::new(crate::qwen::Qwen),
197        Arc::new(crate::devin::Devin),
198        Arc::new(crate::agents_md::AgentsMd),
199    ]
200}
201
202/// The built-in harness with id `id`.
203pub fn find(id: &str) -> Option<Arc<dyn Harness>> {
204    builtin().into_iter().find(|h| h.id() == id)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn reads_and_context() {
213        let r = Reads::always(["AGENTS.md"]).maybe(["CLAUDE.md"]);
214        assert!(r.loads("AGENTS.md") && !r.loads("CLAUDE.md"));
215        assert!(r.may_load("CLAUDE.md") && !r.may_load("X"));
216        let cx = Context::new("t", Scope::Project, "/p", Some("/h".into()));
217        assert_eq!(cx.path("a/b"), Path::new("/p/a/b"));
218        assert_eq!(cx.user_path(".x"), Some(PathBuf::from("/h/.x")));
219        assert!(!cx.is_file("nope"));
220        assert_eq!(
221            Context::new("t", Scope::User, "/h", None).user_path("x"),
222            None
223        );
224    }
225
226    // @zen-test: KIT-18_AC-2
227    #[test]
228    fn built_in_harnesses_are_found_by_id() {
229        let ids: Vec<_> = builtin().iter().map(|h| h.id().to_string()).collect();
230        for id in &ids {
231            assert_eq!(find(id).unwrap().id(), id);
232        }
233        assert!(find("nope").is_none());
234    }
235}
236
237#[cfg(test)]
238mod properties {
239    use proptest::prelude::*;
240    use serde_json::Value;
241
242    use super::*;
243    use crate::{
244        integration::{Agent, Command, Hook, McpServer, Skill},
245        test_support::temp_dir,
246    };
247
248    fn arb_integration() -> impl Strategy<Value = Integration> {
249        (
250            prop::option::of("[a-z ]{1,12}"),
251            prop::collection::vec("[a-z]{1,6}", 0..3),
252            prop::collection::vec(0usize..7, 0..3),
253            prop::bool::ANY,
254            prop::bool::ANY,
255        )
256            .prop_map(|(block, skills, events, agent, command)| {
257                let mut i = Integration::new();
258                if let Some(b) = block {
259                    i = i.instructions(format!("{b}\n"));
260                }
261                for s in skills {
262                    i = i.skill(Skill::new(s.clone(), "d", format!("# {s}\n")));
263                }
264                for e in events {
265                    i = i.hook(Hook::new(Event::ALL[e], "t hook {harness} {event}"));
266                }
267                if agent {
268                    i = i.agent(Agent::new("a", "d", "p\n"));
269                }
270                if command {
271                    i = i.command(Command::new("c", "d", "p $ARGUMENTS\n"));
272                }
273                i.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
274                    .allow_command("t")
275            })
276    }
277
278    fn arb_answer() -> impl Strategy<Value = Answer> {
279        prop_oneof![
280            prop::option::of("[a-z]{0,5}").prop_map(|stderr| Answer::Allow { stderr }),
281            "[a-z \"]{0,8}".prop_map(|reason| Answer::Deny { reason }),
282            "[a-z \"]{0,8}".prop_map(|reason| Answer::Continue { reason }),
283            "[a-z \"]{0,8}".prop_map(|text| Answer::Context { text }),
284        ]
285    }
286
287    fn arb_object() -> impl Strategy<Value = Value> {
288        let leaf = prop_oneof![
289            Just(Value::Null),
290            any::<bool>().prop_map(Value::from),
291            (0i64..100).prop_map(Value::from),
292            "[a-z_]{0,6}".prop_map(Value::from),
293        ];
294        let keys = prop::sample::select(vec![
295            "session_id",
296            "cwd",
297            "tool_name",
298            "tool_input",
299            "toolName",
300            "toolArgs",
301            "stop_hook_active",
302            "loop_count",
303            "prompt",
304            "workspace_roots",
305            "cursor_version",
306            "conversation_id",
307            "event",
308            "continuing",
309            "x",
310        ]);
311        prop::collection::btree_map(keys, leaf, 0..6)
312            .prop_map(|m| Value::Object(m.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))
313    }
314
315    proptest! {
316        #![proptest_config(ProptestConfig::with_cases(64))]
317
318        // @zen-test: HAR_P-1
319        #[test]
320        fn answers_are_well_formed(event in 0usize..7, answer in arb_answer()) {
321            for h in builtin() {
322                let event = Event::ALL[event];
323                match h.answer(event, &answer) {
324                    Ok(out) => {
325                        prop_assert_eq!(out.exit, 0);
326                        let v: Value = serde_json::from_str(&out.stdout).unwrap();
327                        prop_assert!(v.is_object(), "{}: {}", h.id(), out.stdout);
328                    }
329                    Err(e) => prop_assert!(matches!(e, crate::Error::Unsupported { .. }), "{e}"),
330                }
331            }
332        }
333
334        // @zen-test: HAR_P-3
335        #[test]
336        fn any_object_parses(event in 0usize..7, raw in arb_object()) {
337            for h in builtin() {
338                let input = h.parse_hook(Event::ALL[event], &raw.to_string()).unwrap();
339                prop_assert_eq!(input.event, Some(Event::ALL[event]));
340            }
341        }
342
343        // @zen-test: HAR_P-2
344        #[test]
345        fn shared_locations_get_equal_parts(i in arb_integration(), user in any::<bool>()) {
346            let dir = temp_dir("har-p2");
347            let scope = if user { Scope::User } else { Scope::Project };
348            let cx = Context::new("t", scope, &dir, Some(dir.clone()));
349            let mut seen: Vec<(String, String, String, Part)> = Vec::new();
350            for h in builtin() {
351                for p in h.render(&i, &cx).unwrap() {
352                    let at = p.location();
353                    if let Some((_, _, other, q)) = seen.iter().find(|(n, l, _, _)| n == p.name() && *l == at) {
354                        prop_assert!(p.same_content(q), "{} and {other} differ at {at}", h.id());
355                    }
356                    seen.push((p.name().to_string(), at, h.id().to_string(), p));
357                }
358            }
359            std::fs::remove_dir_all(&dir).unwrap();
360        }
361    }
362}