Skip to main content

agent_harness_adapter_core/hook/
answer.rs

1//! A hook's answer, the same for every harness, and `emit`, which writes it
2//! in the harness's form.
3
4use std::{fmt::Display, io::Write};
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::{
10    cli::{EXIT_ERRORS, EXIT_OK},
11    harness::Harness,
12    hook::Event,
13};
14
15/// What a hook decides. Each harness renders it in its own form
16/// (`Harness::answer`).
17///
18/// As JSON (the hook contract's answer): `{"answer": "allow",
19/// "stderr"?}`, `{"answer": "deny", "reason"}`, `{"answer": "continue",
20/// "reason"}` or `{"answer": "context", "text"}`.
21// @zen-impl: KIT-11_AC-4
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "answer", rename_all = "lowercase")]
25#[non_exhaustive]
26pub enum Answer {
27    /// Nothing to say; at stop, the agent may stop. `stderr` carries text
28    /// for the user, e.g. a report the hook already blocked on.
29    Allow {
30        /// Text for stderr, when there is one.
31        #[serde(default, skip_serializing_if = "Option::is_none")]
32        stderr: Option<String>,
33    },
34    /// Before a tool or a prompt: refuse it, with the reason.
35    Deny {
36        /// The reason, shown to the agent and the user.
37        reason: String,
38    },
39    /// At stop: keep the agent going, with the reason as its input.
40    Continue {
41        /// The reason.
42        reason: String,
43    },
44    /// Session start, prompt submit, after a tool: text added to the agent's
45    /// context.
46    Context {
47        /// The text.
48        text: String,
49    },
50}
51
52impl Answer {
53    /// The answer's name in an error, e.g. `deny`.
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Answer::Allow { .. } => "allow",
57            Answer::Deny { .. } => "deny",
58            Answer::Continue { .. } => "continue",
59            Answer::Context { .. } => "context",
60        }
61    }
62
63    /// The text for stderr, when there is one.
64    pub fn stderr(&self) -> Option<&str> {
65        match self {
66            Answer::Allow { stderr } => stderr.as_deref(),
67            _ => None,
68        }
69    }
70}
71
72/// What a hook command writes and its exit code, as a harness renders an
73/// [`Answer`].
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct Output {
77    /// The text for stdout, without a final newline.
78    pub stdout: String,
79    /// The text for stderr, when there is one.
80    pub stderr: Option<String>,
81    /// The exit code.
82    pub exit: u8,
83}
84
85impl Output {
86    /// `value` as compact JSON on stdout, exit 0.
87    pub fn json(value: Value) -> Self {
88        Output {
89            stdout: value.to_string(),
90            stderr: None,
91            exit: EXIT_OK,
92        }
93    }
94
95    /// With `text` for stderr.
96    #[must_use]
97    pub fn stderr(mut self, text: Option<String>) -> Self {
98        self.stderr = text;
99        self
100    }
101
102    /// With exit code `exit`.
103    #[must_use]
104    pub fn exit(mut self, exit: u8) -> Self {
105        self.exit = exit;
106        self
107    }
108}
109
110/// Write a hook's outcome through `harness` and return the exit code. An
111/// answer: its stderr text, then the harness's stdout text and a newline,
112/// and the harness's exit code. A failure, or an answer the harness cannot
113/// express for `event`: `error: <message>` on stderr, nothing on stdout,
114/// exit 1.
115// @zen-impl: KIT-11_AC-5
116pub fn emit<E: Display>(
117    harness: &dyn Harness,
118    event: Event,
119    result: Result<Answer, E>,
120    stdout: &mut impl Write,
121    stderr: &mut impl Write,
122) -> std::io::Result<u8> {
123    let rendered = result
124        .map_err(|e| e.to_string())
125        .and_then(|a| harness.answer(event, &a).map_err(|e| e.to_string()));
126    match rendered {
127        Ok(out) => {
128            if let Some(text) = &out.stderr {
129                stderr.write_all(text.as_bytes())?;
130            }
131            stdout.write_all(format!("{}\n", out.stdout).as_bytes())?;
132            stdout.flush()?;
133            Ok(out.exit)
134        }
135        Err(e) => {
136            writeln!(stderr, "error: {e}")?;
137            Ok(EXIT_ERRORS)
138        }
139    }
140}