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}