1use async_trait::async_trait;
2use cookie_agent_engine::{
3 Engine, PreparedExecutor, PreparedTool, SessionToolContext, ToolCall, ToolError,
4 ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec,
5};
6use cookie_agent_protocol::{
7 ApprovalResourceSource, GoalGetParams, GoalUpdateParams, PermissionAction,
8 PersistedToolResult as ToolResult, PreparedBindingLifetime, PreparedOperationIdentity,
9 SessionId,
10};
11
12use crate::{parse_args, prepared_operation, prepared_resource, safe_title, schema, tool_error};
13
14pub struct GoalTools {
15 engine: Engine,
16}
17
18enum GoalExecutor {
19 Get {
20 engine: Engine,
21 session: SessionId,
22 },
23 Update {
24 engine: Engine,
25 session: SessionId,
26 params: GoalUpdateParams,
27 },
28}
29
30impl GoalTools {
31 #[must_use]
32 pub fn new(engine: Engine) -> Self {
33 Self { engine }
34 }
35
36 fn get_spec() -> ToolSpec {
37 ToolSpec {
38output: Default::default(),
39 concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
40 result_truncation: cookie_agent_engine::ToolResultTruncationPolicy::OptOut,
41 name: "goal_get".into(),
42 permission_name: "read".into(),
43 description: "Read the root session's objective, lifecycle status, revision, and full checklist. The root checklist is authoritative; verify work directly or through subagents before treating an item as finished.".into(),
44 parameters: schema::<GoalGetParams>(),
45 }
46 }
47
48 fn update_spec() -> ToolSpec {
49 ToolSpec {
50output: Default::default(),
51 concurrency: Default::default(),
52 result_truncation: cookie_agent_engine::ToolResultTruncationPolicy::OptOut,
53 name: "goal_update".into(),
54 permission_name: "write".into(),
55 description: "Replace the current root goal's full checklist. The current active or paused goal is selected when this update executes, even if the goal changed since this run started. The last accepted replacement wins. This cannot change the objective or issue lifecycle commands; a nonempty all-finished checklist completes the goal. Verify evidence directly or through subagents before marking items finished; the root checklist is authoritative over subagent self-reports.".into(),
56 parameters: schema::<GoalUpdateParams>(),
57 }
58 }
59
60 fn update_operation(
61 session: SessionId,
62 params: &GoalUpdateParams,
63 ) -> Result<PreparedOperationIdentity, ToolError> {
64 let binding = serde_json::to_vec(&(session, params)).map_err(tool_error)?;
65 let resource = prepared_resource(
66 PermissionAction::Write,
67 "goal",
68 b"goal:current",
69 &binding,
70 PreparedBindingLifetime::RestartStable,
71 ApprovalResourceSource::PrimaryOperation,
72 )?;
73 prepared_operation(
74 "goal_update",
75 params,
76 vec![(PermissionAction::Write, "replace_checklist")],
77 vec![resource],
78 session.to_string().as_bytes(),
79 )
80 }
81}
82
83#[async_trait]
84impl ToolProvider for GoalTools {
85 fn provider_id(&self) -> &'static str {
86 "builtin.goal"
87 }
88
89 fn tools_for_session(&self, _ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
90 Ok(vec![Self::get_spec(), Self::update_spec()])
91 }
92
93 fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
94 match tool_name {
95 "goal_get" => Ok("read"),
96 "goal_update" => Ok("write"),
97 _ => Err(ToolError::execution("goal provider received another tool")),
98 }
99 }
100
101 fn get_permission_resource(
102 &self,
103 name: &str,
104 arguments: &serde_json::Value,
105 ) -> Result<(&'static str, Option<String>), ToolError> {
106 match name {
107 "goal_get" => {
108 let _: GoalGetParams = parse_args(name, arguments.clone())?;
109 Ok(("read", Some("goal:current".into())))
110 }
111 "goal_update" => {
112 let _: GoalUpdateParams = parse_args(name, arguments.clone())?;
113 Ok(("write", Some("goal:current".into())))
114 }
115 _ => Err(ToolError::execution("goal provider received another tool")),
116 }
117 }
118
119 fn get_display_argument(
120 &self,
121 name: &str,
122 arguments: &serde_json::Value,
123 ) -> Result<String, ToolError> {
124 match name {
125 "goal_get" => {
126 let _: GoalGetParams = parse_args(name, arguments.clone())?;
127 Ok("current goal".into())
128 }
129 "goal_update" => {
130 let _: GoalUpdateParams = parse_args(name, arguments.clone())?;
131 Ok("current goal".into())
132 }
133 _ => Err(ToolError::execution("goal provider received another tool")),
134 }
135 }
136
137 async fn prepare(
138 &self,
139 ctx: ToolPreparationContext,
140 call: ToolCall,
141 ) -> Result<PreparedTool, ToolError> {
142 let (normalized_arguments, operation, policy_label, executor) = match call.name.as_str() {
143 "goal_get" => {
144 let params: GoalGetParams = parse_args(&call.name, call.arguments)?;
145 let label = "goal:current".to_owned();
146 let resource = prepared_resource(
147 PermissionAction::Read,
148 "goal",
149 label.as_bytes(),
150 ctx.session.to_string().as_bytes(),
151 PreparedBindingLifetime::RestartStable,
152 ApprovalResourceSource::PrimaryOperation,
153 )?;
154 let operation = prepared_operation(
155 "goal_get",
156 ¶ms,
157 vec![(PermissionAction::Read, "get")],
158 vec![resource],
159 ctx.session.to_string().as_bytes(),
160 )?;
161 (
162 serde_json::to_value(params).map_err(tool_error)?,
163 operation,
164 label,
165 GoalExecutor::Get {
166 engine: self.engine.clone(),
167 session: ctx.session,
168 },
169 )
170 }
171 "goal_update" => {
172 let params: GoalUpdateParams = parse_args(&call.name, call.arguments)?;
173 let label = "goal:current".to_owned();
174 let normalized = serde_json::to_value(¶ms).map_err(tool_error)?;
175 let operation = Self::update_operation(ctx.session, ¶ms)?;
176 (
177 normalized,
178 operation,
179 label,
180 GoalExecutor::Update {
181 engine: self.engine.clone(),
182 session: ctx.session,
183 params,
184 },
185 )
186 }
187 _ => return Err(ToolError::execution("goal provider received another tool")),
188 };
189
190 PreparedTool::new(operation, normalized_arguments, None, Box::new(executor))?
191 .with_policy_labels(vec![policy_label])
192 }
193}
194
195#[async_trait]
196impl PreparedExecutor for GoalExecutor {
197 async fn revalidate(&self) -> Result<(), ToolError> {
198 Ok(())
199 }
200
201 async fn execute(
202 self: Box<Self>,
203 context: ToolExecutionContext,
204 ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
205 let result: Result<ToolResult, ToolError> = async move {
206 if context.cancellation.is_cancelled() {
207 return Err(ToolError::execution("goal tool was cancelled"));
208 }
209 let (title, result) = match *self {
210 Self::Get { engine, session } => {
211 if context.session != session {
212 return Err(ToolError::operation_changed("session changed"));
213 }
214 (
215 "Current goal",
216 serde_json::to_value(engine.goal_get(session).await.map_err(tool_error)?)
217 .map_err(tool_error)?,
218 )
219 }
220 Self::Update {
221 engine,
222 session,
223 params,
224 } => {
225 if context.session != session {
226 return Err(ToolError::operation_changed("session changed"));
227 }
228 (
229 "Updated goal checklist",
230 serde_json::to_value(
231 engine
232 .goal_update(session, params)
233 .await
234 .map_err(tool_error)?,
235 )
236 .map_err(tool_error)?,
237 )
238 }
239 };
240 goal_result(title, result)
241 }
242 .await;
243 result.map(cookie_agent_engine::ToolCompletion::single)
244 }
245}
246
247fn goal_result(title: &str, result: serde_json::Value) -> Result<ToolResult, ToolError> {
248 Ok(ToolResult {
249 display: None,
250 retained_output: None,
251 title: safe_title(title),
252 output: serde_json::to_string_pretty(&result).map_err(tool_error)?,
253 metadata: serde_json::Value::Null,
254 truncation: None,
255 attachments: Vec::new(),
256 additional_messages: Vec::new(),
257 })
258}
259
260#[cfg(test)]
261mod tests {
262 use cookie_agent_engine::ToolProvider;
263
264 use super::GoalTools;
265
266 #[test]
267 fn goal_results_keep_pretty_json_only_in_output() {
268 let value = serde_json::json!({"goal": {"items": [{"description": "Verify checklist", "finished": false}]}});
269 for title in ["Current goal", "Updated goal checklist"] {
270 let result = super::goal_result(title, value.clone()).unwrap();
271 assert_eq!(result.title.as_str(), title);
272 assert_eq!(result.output, serde_json::to_string_pretty(&value).unwrap());
273 assert!(result.metadata.is_null());
274 assert_eq!(
275 serde_json::to_string(&result)
276 .unwrap()
277 .matches("Verify checklist")
278 .count(),
279 1
280 );
281 }
282 }
283
284 #[test]
285 fn goal_specs_use_strict_protocol_schemas_and_root_authority_guidance() {
286 let get = GoalTools::get_spec();
287 let update = GoalTools::update_spec();
288 assert_eq!(get.permission_name, "read");
289 assert_eq!(update.permission_name, "write");
290 assert_eq!(
291 get.result_truncation,
292 cookie_agent_engine::ToolResultTruncationPolicy::OptOut
293 );
294 assert_eq!(
295 update.result_truncation,
296 cookie_agent_engine::ToolResultTruncationPolicy::OptOut
297 );
298 assert_eq!(get.parameters["additionalProperties"], false);
299 assert_eq!(update.parameters["additionalProperties"], false);
300 assert_eq!(update.parameters["required"], serde_json::json!(["items"]));
301 assert_eq!(
302 update.parameters["properties"].as_object().unwrap().len(),
303 1
304 );
305 assert!(!update.description.contains("expected revision"));
306 assert!(!update.description.contains("goal ID"));
307 assert!(
308 update
309 .description
310 .contains("last accepted replacement wins")
311 );
312 assert!(get.description.contains("root checklist is authoritative"));
313 assert!(update.description.contains("before marking items finished"));
314 }
315
316 #[test]
317 fn strict_goal_arguments_reject_unknown_fields() {
318 let get = serde_json::json!({"unexpected":true});
319 assert!(
320 crate::parse_args::<cookie_agent_protocol::GoalGetParams>("goal_get", get).is_err()
321 );
322 assert!(
323 GoalTools::get_permission_name("goal_lifecycle").is_err(),
324 "the model must not receive a lifecycle tool"
325 );
326 let goal_id = cookie_agent_protocol::GoalId::new_v7();
327 for arguments in [
328 serde_json::json!({"items":[],"expected_revision":0}),
329 serde_json::json!({"items":[],"goal_id":goal_id}),
330 serde_json::json!({"items":[],"id":"old-item"}),
331 serde_json::json!({"items":[{"id":"old-item","description":"Verify","finished":false}]}),
332 ] {
333 assert!(
334 crate::parse_args::<cookie_agent_protocol::GoalUpdateParams>(
335 "goal_update",
336 arguments
337 )
338 .is_err()
339 );
340 }
341 let arguments = serde_json::json!({"items":[{"description":"Verify","finished":false},{"description":"Verify","finished":false}]});
342 let parsed = crate::parse_args::<cookie_agent_protocol::GoalUpdateParams>(
343 "goal_update",
344 arguments.clone(),
345 )
346 .unwrap();
347 assert_eq!(serde_json::to_value(parsed).unwrap(), arguments);
348 }
349
350 #[test]
351 fn update_fingerprint_binds_session_and_exact_items_only() {
352 use cookie_agent_protocol::{
353 GoalItem, GoalUpdateParams, OperationFingerprint, SessionId, Sha256Digest,
354 };
355
356 let session = SessionId::new_v7();
357 let params = GoalUpdateParams {
358 items: vec![GoalItem {
359 description: "Verify".into(),
360 finished: false,
361 }],
362 };
363 let operation = GoalTools::update_operation(session, ¶ms).unwrap();
364 let fingerprint = OperationFingerprint::from_prepared_operation(&operation);
365 assert_eq!(
366 fingerprint,
367 OperationFingerprint::from_prepared_operation(
368 &GoalTools::update_operation(session, ¶ms).unwrap()
369 )
370 );
371 assert_ne!(
372 fingerprint,
373 OperationFingerprint::from_prepared_operation(
374 &GoalTools::update_operation(SessionId::new_v7(), ¶ms).unwrap()
375 )
376 );
377 let mut changed = params.clone();
378 changed.items[0].finished = true;
379 assert_ne!(
380 fingerprint,
381 OperationFingerprint::from_prepared_operation(
382 &GoalTools::update_operation(session, &changed).unwrap()
383 )
384 );
385 changed = params.clone();
386 changed.items[0].description = "Verify again".into();
387 assert_ne!(
388 GoalTools::update_operation(session, ¶ms)
389 .unwrap()
390 .resources()[0]
391 .binding_digest,
392 GoalTools::update_operation(session, &changed)
393 .unwrap()
394 .resources()[0]
395 .binding_digest
396 );
397 let wire = serde_json::to_value(&operation).unwrap();
398 assert_eq!(
399 wire["normalized_arguments_digest"],
400 serde_json::to_value(Sha256Digest::of_bytes(
401 &serde_json::to_vec(¶ms).unwrap()
402 ))
403 .unwrap()
404 );
405 assert_eq!(
406 wire["execution_context_digest"],
407 serde_json::to_value(Sha256Digest::of_bytes(session.to_string().as_bytes())).unwrap()
408 );
409 assert_eq!(
410 operation.resources()[0].capability,
411 cookie_agent_protocol::PermissionAction::Write
412 );
413 }
414}