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}