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}