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