1use std::sync::Mutex;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use starweaver_context::{AgentContext, AgentEvent};
9
10use crate::{
11 capability::{AgentCapability, CapabilityError, CapabilityResult, CapabilitySpec},
12 run::AgentRunState,
13};
14
15pub const GOAL_CAPABILITY_ID: &str = "starweaver.goal";
17pub const GOAL_COMPLETE_MARKER: &str = "[GOAL_COMPLETE]";
19pub const GOAL_ITERATION_EVENT_KIND: &str = "goal_iteration";
21pub const GOAL_COMPLETE_EVENT_KIND: &str = "goal_complete";
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26pub struct GoalRunOptions {
27 objective: String,
28 max_iterations: usize,
29}
30
31impl GoalRunOptions {
32 #[must_use]
34 pub fn new(objective: impl Into<String>, max_iterations: usize) -> Self {
35 Self {
36 objective: objective.into(),
37 max_iterations: max_iterations.max(1),
38 }
39 }
40
41 #[must_use]
43 pub fn objective(&self) -> &str {
44 &self.objective
45 }
46
47 #[must_use]
49 pub const fn max_iterations(&self) -> usize {
50 self.max_iterations
51 }
52}
53
54#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum GoalCompleteReason {
58 Verified,
60 MaxIterations,
62 Cancelled,
64 Error,
66 UnverifiedStop,
68}
69
70impl GoalCompleteReason {
71 #[must_use]
73 pub const fn as_str(self) -> &'static str {
74 match self {
75 Self::Verified => "verified",
76 Self::MaxIterations => "max_iterations",
77 Self::Cancelled => "cancelled",
78 Self::Error => "error",
79 Self::UnverifiedStop => "unverified_stop",
80 }
81 }
82}
83
84#[derive(Debug)]
86pub struct GoalCapability {
87 options: GoalRunOptions,
88 state: Mutex<GoalRuntimeState>,
89}
90
91impl GoalCapability {
92 #[must_use]
94 pub fn new(options: GoalRunOptions) -> Self {
95 Self {
96 options,
97 state: Mutex::new(GoalRuntimeState::default()),
98 }
99 }
100
101 fn continue_or_stop_action(
102 &self,
103 state: &mut GoalRuntimeState,
104 prompt: String,
105 ) -> GoalValidationAction {
106 let next_iteration = state.iteration.saturating_add(1);
107 if next_iteration > self.options.max_iterations {
108 state.completed = true;
109 return GoalValidationAction::Complete {
110 iteration: state.iteration,
111 reason: GoalCompleteReason::MaxIterations,
112 };
113 }
114
115 state.iteration = next_iteration;
116 GoalValidationAction::Retry {
117 iteration: state.iteration,
118 prompt,
119 }
120 }
121
122 fn sync_context_handoff_state(context: &AgentContext, state: &mut GoalRuntimeState) {
123 let events = context.events.events();
124 let start = state.observed_event_cursor.min(events.len());
125 for event in &events[start..] {
126 if let Some(source) = context_handoff_source(&event.kind) {
127 state.needs_post_restore_audit = true;
128 state.last_context_handoff_source = Some(source.to_string());
129 }
130 }
131 state.observed_event_cursor = events.len();
132 }
133}
134
135#[async_trait]
136impl AgentCapability for GoalCapability {
137 fn spec(&self) -> CapabilitySpec {
138 CapabilitySpec::new(GOAL_CAPABILITY_ID)
139 }
140
141 async fn on_run_start_with_context(
142 &self,
143 _state: &mut AgentRunState,
144 context: &mut AgentContext,
145 ) -> CapabilityResult<()> {
146 {
147 let mut state = self.state.lock().map_err(|_| {
148 CapabilityError::Failed("goal capability state lock poisoned".to_string())
149 })?;
150 *state = GoalRuntimeState {
151 observed_event_cursor: context.events.len(),
152 ..GoalRuntimeState::default()
153 };
154 }
155 Ok(())
156 }
157
158 async fn validate_output_with_context(
159 &self,
160 _state: &mut AgentRunState,
161 context: &mut AgentContext,
162 output: &str,
163 ) -> CapabilityResult<()> {
164 let action = {
165 let mut state = self.state.lock().map_err(|_| {
166 CapabilityError::Failed("goal capability state lock poisoned".to_string())
167 })?;
168 let action = if state.completed {
169 GoalValidationAction::Done
170 } else {
171 Self::sync_context_handoff_state(context, &mut state);
172
173 if has_completion_marker(output) {
174 if state.needs_post_restore_audit {
175 state.needs_post_restore_audit = false;
176 let prompt = build_post_restore_goal_audit_prompt(
177 self.options.objective(),
178 state
179 .last_context_handoff_source
180 .as_deref()
181 .unwrap_or("context"),
182 );
183 self.continue_or_stop_action(&mut state, prompt)
184 } else {
185 state.completed = true;
186 GoalValidationAction::Complete {
187 iteration: state.iteration,
188 reason: GoalCompleteReason::Verified,
189 }
190 }
191 } else {
192 let next_iteration = state.iteration.saturating_add(1);
193 self.continue_or_stop_action(
194 &mut state,
195 build_goal_check_prompt(
196 self.options.objective(),
197 next_iteration,
198 self.options.max_iterations(),
199 ),
200 )
201 }
202 };
203 drop(state);
204 action
205 };
206
207 match action {
208 GoalValidationAction::Done => Ok(()),
209 GoalValidationAction::Retry { iteration, prompt } => {
210 publish_goal_iteration(context, &self.options, iteration);
211 Err(CapabilityError::ModelRetry(prompt))
212 }
213 GoalValidationAction::Complete { iteration, reason } => {
214 publish_goal_complete(context, &self.options, iteration, reason);
215 Ok(())
216 }
217 }
218 }
219}
220
221#[derive(Debug)]
222enum GoalValidationAction {
223 Done,
224 Retry {
225 iteration: usize,
226 prompt: String,
227 },
228 Complete {
229 iteration: usize,
230 reason: GoalCompleteReason,
231 },
232}
233
234#[derive(Debug, Default)]
235struct GoalRuntimeState {
236 iteration: usize,
237 completed: bool,
238 needs_post_restore_audit: bool,
239 last_context_handoff_source: Option<String>,
240 observed_event_cursor: usize,
241}
242
243fn publish_goal_iteration(context: &mut AgentContext, options: &GoalRunOptions, iteration: usize) {
244 context.publish_event(AgentEvent::new(
245 GOAL_ITERATION_EVENT_KIND,
246 json!({
247 "iteration": iteration,
248 "max_iterations": options.max_iterations(),
249 "task": options.objective(),
250 }),
251 ));
252}
253
254fn publish_goal_complete(
255 context: &mut AgentContext,
256 options: &GoalRunOptions,
257 iteration: usize,
258 reason: GoalCompleteReason,
259) {
260 context.publish_event(AgentEvent::new(
261 GOAL_COMPLETE_EVENT_KIND,
262 json!({
263 "iteration": iteration,
264 "max_iterations": options.max_iterations(),
265 "reason": reason.as_str(),
266 "task": options.objective(),
267 }),
268 ));
269}
270
271#[must_use]
273pub fn has_completion_marker(output: &str) -> bool {
274 output
275 .lines()
276 .any(|line| line.trim() == GOAL_COMPLETE_MARKER)
277}
278
279#[must_use]
281pub fn build_goal_check_prompt(objective: &str, iteration: usize, max_iterations: usize) -> String {
282 format!(
283 "Continue working toward the active goal.\n\n<objective>\n{}\n</objective>\n\n<goal-check>\nCurrent iteration: {}/{}.\nIf the goal is fully complete, include {} on its own line.\nOtherwise, make concrete progress and continue.\n</goal-check>",
284 escape_xml_text(objective),
285 iteration,
286 max_iterations.max(1),
287 GOAL_COMPLETE_MARKER,
288 )
289}
290
291#[must_use]
293pub fn build_post_restore_goal_audit_prompt(objective: &str, source: &str) -> String {
294 format!(
295 "The previous response claimed the active goal was complete after a {} handoff. Re-audit the restored context before stopping.\n\n<objective>\n{}\n</objective>\n\n<goal-check>\nUse fresh evidence from the current context. If the goal is still fully complete, include {} on its own line. Otherwise, continue with the next concrete step.\n</goal-check>",
296 escape_xml_text(source),
297 escape_xml_text(objective),
298 GOAL_COMPLETE_MARKER,
299 )
300}
301
302fn context_handoff_source(kind: &str) -> Option<&'static str> {
303 let normalized = kind.to_ascii_lowercase().replace(['.', '-'], "_");
304 if matches!(
305 normalized.as_str(),
306 "compact_complete" | "compact_completed" | "compaction_complete" | "compaction_completed"
307 ) || normalized.ends_with("_compact_complete")
308 || normalized.ends_with("_compact_completed")
309 || normalized.ends_with("_compaction_complete")
310 || normalized.ends_with("_compaction_completed")
311 {
312 return Some("context compaction");
313 }
314 if matches!(
315 normalized.as_str(),
316 "handoff_complete" | "handoff_completed" | "summary_complete" | "summary_completed"
317 ) || normalized.ends_with("_handoff_complete")
318 || normalized.ends_with("_handoff_completed")
319 || normalized.ends_with("_summary_complete")
320 || normalized.ends_with("_summary_completed")
321 {
322 return Some("summary");
323 }
324 None
325}
326
327fn escape_xml_text(value: &str) -> String {
328 let mut escaped = String::with_capacity(value.len());
329 for ch in value.chars() {
330 match ch {
331 '&' => escaped.push_str("&"),
332 '<' => escaped.push_str("<"),
333 '>' => escaped.push_str(">"),
334 '"' => escaped.push_str("""),
335 '\'' => escaped.push_str("'"),
336 ch => escaped.push(ch),
337 }
338 }
339 escaped
340}
341
342#[cfg(test)]
343mod tests {
344 use std::sync::{
345 Arc,
346 atomic::{AtomicUsize, Ordering},
347 };
348
349 use starweaver_core::{AgentId, ConversationId, RunId};
350 use starweaver_model::{
351 FunctionModel, FunctionModelInfo, ModelMessage, ModelResponse, ModelSettings,
352 };
353
354 use super::*;
355 use crate::{Agent, OutputPolicy, stream::AgentStreamEvent};
356
357 #[test]
358 fn marker_must_be_on_its_own_line() {
359 assert!(has_completion_marker("done\n[GOAL_COMPLETE]\n"));
360 assert!(has_completion_marker(" [GOAL_COMPLETE] "));
361 assert!(!has_completion_marker("done [GOAL_COMPLETE]"));
362 assert!(!has_completion_marker("[GOAL_COMPLETE] but more text"));
363 }
364
365 #[test]
366 fn continuation_prompt_escapes_objective() {
367 let prompt = build_goal_check_prompt("ship <a&b>", 1, 3);
368 assert!(prompt.contains("ship <a&b>"));
369 assert!(prompt.contains("Current iteration: 1/3."));
370 }
371
372 #[tokio::test]
373 async fn goal_retries_until_marker_or_budget() {
374 let capability = GoalCapability::new(GoalRunOptions::new("finish task", 1));
375 let mut run_state = AgentRunState::new(RunId::new(), ConversationId::new());
376 let mut context = AgentContext::new(AgentId::default());
377
378 capability
379 .on_run_start_with_context(&mut run_state, &mut context)
380 .await
381 .unwrap_or_else(|error| panic!("goal start failed: {error}"));
382 let retry = capability
383 .validate_output_with_context(&mut run_state, &mut context, "not done")
384 .await;
385 let Err(retry) = retry else {
386 panic!("goal validation should retry without marker");
387 };
388 assert!(matches!(retry, CapabilityError::ModelRetry(_)));
389 assert_eq!(context.events.events()[0].kind, GOAL_ITERATION_EVENT_KIND);
390
391 capability
392 .validate_output_with_context(&mut run_state, &mut context, "still not done")
393 .await
394 .unwrap_or_else(|error| panic!("goal budget completion failed: {error}"));
395 let Some(complete) = context.events.events().last() else {
396 panic!("goal completion event should be published");
397 };
398 assert_eq!(complete.kind, GOAL_COMPLETE_EVENT_KIND);
399 assert_eq!(complete.payload["reason"], "max_iterations");
400 }
401
402 #[tokio::test]
403 async fn completion_after_handoff_requires_audit_retry() {
404 let capability = GoalCapability::new(GoalRunOptions::new("finish task", 2));
405 let mut run_state = AgentRunState::new(RunId::new(), ConversationId::new());
406 let mut context = AgentContext::new(AgentId::default());
407
408 capability
409 .on_run_start_with_context(&mut run_state, &mut context)
410 .await
411 .unwrap_or_else(|error| panic!("goal start failed: {error}"));
412 context.publish_event(AgentEvent::new("compact_complete", json!({})));
413 let retry = capability
414 .validate_output_with_context(&mut run_state, &mut context, "done\n[GOAL_COMPLETE]")
415 .await;
416 let Err(retry) = retry else {
417 panic!("goal validation should retry after context handoff");
418 };
419 assert!(matches!(retry, CapabilityError::ModelRetry(_)));
420 assert!(matches!(
421 context.events.events().last(),
422 Some(event) if event.kind == GOAL_ITERATION_EVENT_KIND
423 ));
424 }
425
426 #[tokio::test]
427 async fn goal_capability_runs_inside_output_retry_loop() {
428 let calls = Arc::new(AtomicUsize::new(0));
429 let model_calls = Arc::clone(&calls);
430 let model = FunctionModel::new(
431 move |_messages: Vec<ModelMessage>,
432 _settings: Option<ModelSettings>,
433 _info: FunctionModelInfo| {
434 let call = model_calls.fetch_add(1, Ordering::SeqCst);
435 if call == 0 {
436 Ok(ModelResponse::text("not done yet"))
437 } else {
438 Ok(ModelResponse::text("done\n[GOAL_COMPLETE]"))
439 }
440 },
441 );
442 let agent = Agent::new(Arc::new(model))
443 .with_output_policy(OutputPolicy::new().with_retries(7))
444 .with_capability(Arc::new(GoalCapability::new(GoalRunOptions::new(
445 "finish task",
446 2,
447 ))));
448
449 let mut events = Vec::new();
450 let result = Box::pin(agent.run_with_stream_events("start", &mut events))
451 .await
452 .unwrap_or_else(|error| panic!("goal run should succeed: {error}"));
453
454 assert_eq!(result.output, "done\n[GOAL_COMPLETE]");
455 assert_eq!(calls.load(Ordering::SeqCst), 2);
456 assert!(events.iter().any(|record| matches!(
457 record.event,
458 AgentStreamEvent::OutputRetry { retries: 1, .. }
459 )));
460 assert!(events.iter().any(|record| {
461 matches!(
462 &record.event,
463 AgentStreamEvent::Custom { event } if event.kind == GOAL_ITERATION_EVENT_KIND
464 )
465 }));
466 assert!(events.iter().any(|record| {
467 matches!(
468 &record.event,
469 AgentStreamEvent::Custom { event }
470 if event.kind == GOAL_COMPLETE_EVENT_KIND
471 && event.payload["reason"] == "verified"
472 )
473 }));
474 }
475}