Skip to main content

atman_runtime/tools/
final_answer.rs

1use crate::error::RuntimeError;
2use crate::message::{Message, MessagePart};
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5
6pub const FINAL_ANSWER_TOOL: &str = "final.answer";
7
8struct Candidate<'a> {
9    answer: &'a str,
10    summary: Option<&'a str>,
11}
12
13fn raw_candidate(message: &Message) -> Result<Option<Candidate<'_>>, &'static str> {
14    let final_calls = message
15        .parts
16        .iter()
17        .filter(
18            |part| matches!(part, MessagePart::ToolUse { name, .. } if name == FINAL_ANSWER_TOOL),
19        )
20        .count();
21    if final_calls == 0 {
22        return Ok(None);
23    }
24    if final_calls != 1 {
25        return Err("final.answer must be the only tool call in the assistant response");
26    }
27    let tool_calls = message
28        .parts
29        .iter()
30        .filter(|part| matches!(part, MessagePart::ToolUse { .. }))
31        .count();
32    if tool_calls != 1 {
33        return Err("final.answer must be emitted without sibling tool calls");
34    }
35    let Some(MessagePart::ToolUse { input, intent, .. }) = message.parts.iter().find(
36        |part| matches!(part, MessagePart::ToolUse { name, .. } if name == FINAL_ANSWER_TOOL),
37    ) else {
38        unreachable!();
39    };
40    let Some(answer) = input
41        .get("message")
42        .and_then(serde_json::Value::as_str)
43        .filter(|answer| !answer.trim().is_empty())
44    else {
45        return Err("final.answer requires a non-empty `message`");
46    };
47    Ok(Some(Candidate {
48        answer,
49        summary: intent.as_ref().map(|intent| intent.as_str()),
50    }))
51}
52
53fn fallback_summary(answer: &str) -> String {
54    let first_line = answer
55        .lines()
56        .map(str::trim)
57        .find(|line| !line.is_empty())
58        .unwrap_or("Completed internal work")
59        .trim_start_matches(['#', '*', '-', '>', '`'])
60        .trim();
61    crate::message::ToolCallIntent::new(first_line)
62        .or_else(|| crate::message::ToolCallIntent::new("Completed internal work"))
63        .expect("fallback final-answer summary is non-empty")
64        .as_str()
65        .to_owned()
66}
67
68pub fn attempted(message: &Message) -> bool {
69    message
70        .parts
71        .iter()
72        .any(|part| matches!(part, MessagePart::ToolUse { name, .. } if name == FINAL_ANSWER_TOOL))
73}
74
75pub fn validation_error(message: &Message) -> Option<&'static str> {
76    raw_candidate(message).err()
77}
78
79pub fn summary(message: &Message) -> Option<String> {
80    message
81        .parts
82        .iter()
83        .find_map(|part| match part {
84            MessagePart::FinalAnswerSummary { text } => {
85                (!text.trim().is_empty()).then(|| text.clone())
86            }
87            MessagePart::ToolUse {
88                name,
89                input,
90                intent,
91                ..
92            } if name == FINAL_ANSWER_TOOL => intent
93                .as_ref()
94                .map(|intent| intent.as_str().to_owned())
95                .or_else(|| {
96                    input
97                        .get("message")
98                        .and_then(serde_json::Value::as_str)
99                        .filter(|answer| !answer.trim().is_empty())
100                        .map(fallback_summary)
101                }),
102            _ => None,
103        })
104        .or_else(|| {
105            (message.origin == crate::message::MessageOrigin::FinalAnswer)
106                .then(|| message.text_concat())
107                .filter(|answer| !answer.trim().is_empty())
108                .map(|answer| fallback_summary(&answer))
109        })
110}
111
112pub fn extract(message: &Message) -> Option<String> {
113    if message.origin == crate::message::MessageOrigin::FinalAnswer {
114        return (!message.text_concat().trim().is_empty()).then(|| message.text_concat());
115    }
116    raw_candidate(message)
117        .ok()
118        .flatten()
119        .map(|candidate| candidate.answer.to_owned())
120}
121
122pub fn normalized_for_history(message: &Message) -> Option<Message> {
123    if message.origin == crate::message::MessageOrigin::FinalAnswer {
124        return extract(message)
125            .and_then(|_| summary(message))
126            .map(|_| message.clone());
127    }
128    let candidate = raw_candidate(message).ok().flatten()?;
129    let mut normalized = message.clone();
130    normalized.origin = crate::message::MessageOrigin::FinalAnswer;
131    normalized
132        .parts
133        .retain(|part| matches!(part, MessagePart::Thinking { .. }));
134    normalized.parts.push(MessagePart::FinalAnswerSummary {
135        text: candidate
136            .summary
137            .map(str::to_owned)
138            .unwrap_or_else(|| fallback_summary(candidate.answer)),
139    });
140    normalized.parts.push(MessagePart::Text {
141        text: candidate.answer.to_owned(),
142    });
143    Some(normalized)
144}
145
146pub struct FinalAnswer;
147
148impl Tool for FinalAnswer {
149    fn name(&self) -> &str {
150        FINAL_ANSWER_TOOL
151    }
152
153    fn tier(&self) -> Tier {
154        Tier::Zero
155    }
156
157    fn description(&self) -> Option<&str> {
158        Some(
159            "Deliver the final user-facing answer after all thinking and tool work is complete. Use _atman_intent to summarize the completed work for the collapsed activity header.",
160        )
161    }
162
163    fn input_schema(&self) -> serde_json::Value {
164        serde_json::json!({
165            "type": "object",
166            "properties": {
167                "message": {
168                    "type": "string",
169                    "description": "Complete final answer in Markdown."
170                }
171            },
172            "required": ["message"]
173        })
174    }
175
176    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
177        Box::pin(async move {
178            match args.named("message").or_else(|| args.positional.first()) {
179                Some(Value::Str(message)) => Ok(Value::Str(message.clone())),
180                Some(other) => Err(RuntimeError::TypeMismatch {
181                    expected: "string".into(),
182                    actual: other.kind_name().into(),
183                }),
184                None => Err(RuntimeError::ToolFailed(
185                    "final.answer: missing `message`".into(),
186                )),
187            }
188        })
189    }
190}
191
192pub struct ExtractFinalAnswer;
193
194impl Tool for ExtractFinalAnswer {
195    fn name(&self) -> &str {
196        "extract_final_answer"
197    }
198
199    fn tier(&self) -> Tier {
200        Tier::Zero
201    }
202
203    fn description(&self) -> Option<&str> {
204        Some("Extract a final.answer control payload from an assistant Message.")
205    }
206
207    fn input_schema(&self) -> serde_json::Value {
208        serde_json::json!({
209            "type": "object",
210            "properties": {"message": {"description": "Assistant Message value."}},
211            "required": ["message"]
212        })
213    }
214
215    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
216        Box::pin(async move {
217            let value = args.named("message").or_else(|| args.positional.first());
218            match value {
219                Some(Value::Message(message)) => {
220                    Ok(extract(message).map(Value::Str).unwrap_or(Value::Unit))
221                }
222                Some(Value::Str(_)) | None => Ok(Value::Unit),
223                Some(other) => Err(RuntimeError::TypeMismatch {
224                    expected: "message or string".into(),
225                    actual: other.kind_name().into(),
226                }),
227            }
228        })
229    }
230}
231
232pub struct FinalizeResponse;
233
234impl Tool for FinalizeResponse {
235    fn name(&self) -> &str {
236        "finalize_response"
237    }
238
239    fn tier(&self) -> Tier {
240        Tier::Zero
241    }
242
243    fn requires_call_intent(&self) -> bool {
244        false
245    }
246
247    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
248        Box::pin(async move {
249            let message = match args.named("message").or_else(|| args.positional.first()) {
250                Some(Value::Message(message)) => message,
251                Some(other) => {
252                    return Err(RuntimeError::TypeMismatch {
253                        expected: "message".into(),
254                        actual: other.kind_name().into(),
255                    });
256                }
257                None => {
258                    return Err(RuntimeError::MissingArg(
259                        "finalize_response: message".into(),
260                    ));
261                }
262            };
263            normalized_for_history(message)
264                .map(Value::Message)
265                .ok_or_else(|| {
266                    RuntimeError::ToolFailed(
267                        validation_error(message)
268                            .unwrap_or("invalid final.answer control")
269                            .into(),
270                    )
271                })
272        })
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::event::TurnId;
280
281    fn control_message() -> Message {
282        Message {
283            turn_id: TurnId::now(),
284            role: crate::message::MessageRole::Assistant,
285            parts: vec![MessagePart::ToolUse {
286                id: "answer-1".into(),
287                name: FINAL_ANSWER_TOOL.into(),
288                input: serde_json::json!({"message": "Done."}),
289                intent: None,
290            }],
291            origin: crate::message::MessageOrigin::User,
292        }
293    }
294
295    #[test]
296    fn final_answer_schema_requires_summary_intent() {
297        let spec = crate::tool::tool_spec(&FinalAnswer);
298        assert_eq!(
299            spec.input_schema["required"],
300            serde_json::json!(["message", "_atman_intent"])
301        );
302        assert!(
303            spec.input_schema["properties"]
304                .get("_atman_intent")
305                .is_some()
306        );
307        assert_eq!(
308            spec.input_schema["properties"]["_atman_intent"],
309            serde_json::json!({
310                "type": "string",
311                "minLength": 1,
312                "maxLength": 120,
313                "pattern": "\\S"
314            })
315        );
316    }
317
318    #[test]
319    fn normalizes_missing_summary_intent_with_answer_fallback() {
320        let normalized = normalized_for_history(&control_message()).unwrap();
321        assert_eq!(normalized.text_concat(), "Done.");
322        assert_eq!(summary(&normalized).as_deref(), Some("Done."));
323        assert_eq!(
324            normalized.origin,
325            crate::message::MessageOrigin::FinalAnswer
326        );
327    }
328
329    #[test]
330    fn normalizes_valid_control_to_plain_assistant_text() {
331        let mut message = control_message();
332        let MessagePart::ToolUse { intent, .. } = &mut message.parts[0] else {
333            unreachable!();
334        };
335        *intent = crate::message::ToolCallIntent::new("Completed requested work.");
336        let normalized = normalized_for_history(&message).unwrap();
337        assert_eq!(normalized.text_concat(), "Done.");
338        assert!(!normalized.parts.iter().any(
339            |part| matches!(part, MessagePart::ToolUse { name, .. } if name == FINAL_ANSWER_TOOL)
340        ));
341    }
342
343    #[test]
344    fn preserves_final_answer_summary_for_replay() {
345        let mut message = control_message();
346        let MessagePart::ToolUse { intent, .. } = &mut message.parts[0] else {
347            unreachable!();
348        };
349        *intent = crate::message::ToolCallIntent::new("Checked the renderer and tests.");
350
351        let normalized = normalized_for_history(&message).unwrap();
352        assert_eq!(
353            summary(&normalized).as_deref(),
354            Some("Checked the renderer and tests.")
355        );
356        assert_eq!(normalized.text_concat(), "Done.");
357    }
358
359    #[test]
360    fn accepts_final_control_with_text_but_rejects_other_tools() {
361        let mut message = control_message();
362        let MessagePart::ToolUse { intent, .. } = &mut message.parts[0] else {
363            unreachable!();
364        };
365        *intent = crate::message::ToolCallIntent::new("Completed requested work.");
366        message.parts.push(MessagePart::Text {
367            text: "preface".into(),
368        });
369        assert_eq!(validation_error(&message), None);
370        assert_eq!(extract(&message).as_deref(), Some("Done."));
371        let normalized = normalized_for_history(&message).unwrap();
372        assert_eq!(normalized.text_concat(), "Done.");
373
374        message.parts.pop();
375        message.parts.push(MessagePart::ToolUse {
376            id: "read-1".into(),
377            name: "fs.read".into(),
378            input: serde_json::json!({"path": "README.md"}),
379            intent: crate::message::ToolCallIntent::new("Read documentation."),
380        });
381        assert_eq!(
382            validation_error(&message),
383            Some("final.answer must be emitted without sibling tool calls")
384        );
385    }
386
387    #[test]
388    fn rejects_empty_or_repeated_final_controls() {
389        let mut message = control_message();
390        let MessagePart::ToolUse { input, intent, .. } = &mut message.parts[0] else {
391            unreachable!();
392        };
393        *input = serde_json::json!({"message": "  "});
394        *intent = crate::message::ToolCallIntent::new("Completed requested work.");
395        assert_eq!(
396            validation_error(&message),
397            Some("final.answer requires a non-empty `message`")
398        );
399
400        message.parts.push(message.parts[0].clone());
401        assert_eq!(
402            validation_error(&message),
403            Some("final.answer must be the only tool call in the assistant response")
404        );
405    }
406}