Skip to main content

starweaver_runtime/
goal.rs

1//! Runtime goal-mode output validator capability.
2
3use 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
15/// Stable capability id for runtime goal mode.
16pub const GOAL_CAPABILITY_ID: &str = "starweaver.goal";
17/// Marker the model must place on its own line when the objective is complete.
18pub const GOAL_COMPLETE_MARKER: &str = "[GOAL_COMPLETE]";
19/// Context event emitted before another goal retry.
20pub const GOAL_ITERATION_EVENT_KIND: &str = "goal_iteration";
21/// Context event emitted when goal mode stops.
22pub const GOAL_COMPLETE_EVENT_KIND: &str = "goal_complete";
23
24/// Goal-mode runtime options.
25#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26pub struct GoalRunOptions {
27    objective: String,
28    max_iterations: usize,
29}
30
31impl GoalRunOptions {
32    /// Create goal-mode options.
33    #[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    /// Return the objective text.
42    #[must_use]
43    pub fn objective(&self) -> &str {
44        &self.objective
45    }
46
47    /// Return the maximum retry iterations.
48    #[must_use]
49    pub const fn max_iterations(&self) -> usize {
50        self.max_iterations
51    }
52}
53
54/// Reason goal mode stopped.
55#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum GoalCompleteReason {
58    /// The model emitted the completion marker and no post-handoff audit is pending.
59    Verified,
60    /// The retry iteration budget was exhausted.
61    MaxIterations,
62    /// The host cancelled the run.
63    Cancelled,
64    /// The run failed while goal mode was active.
65    Error,
66    /// The host stopped goal mode without a verified marker.
67    UnverifiedStop,
68}
69
70impl GoalCompleteReason {
71    /// Stable snake-case reason string.
72    #[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/// Runtime capability that keeps `/goal` inside the agent output retry loop.
85#[derive(Debug)]
86pub struct GoalCapability {
87    options: GoalRunOptions,
88    state: Mutex<GoalRuntimeState>,
89}
90
91impl GoalCapability {
92    /// Create a goal-mode capability.
93    #[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/// Return whether output includes the completion marker on its own line.
272#[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/// Build the standard continuation prompt for goal-mode retry.
280#[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/// Build the verification prompt used after context handoff or compaction.
292#[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("&amp;"),
332            '<' => escaped.push_str("&lt;"),
333            '>' => escaped.push_str("&gt;"),
334            '"' => escaped.push_str("&quot;"),
335            '\'' => escaped.push_str("&apos;"),
336            ch => escaped.push(ch),
337        }
338    }
339    escaped
340}
341
342#[cfg(test)]
343mod tests {
344    use std::sync::{
345        atomic::{AtomicUsize, Ordering},
346        Arc,
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::{stream::AgentStreamEvent, Agent, OutputPolicy};
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 &lt;a&amp;b&gt;"));
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 = agent
451            .run_with_stream_events("start", &mut events)
452            .await
453            .unwrap_or_else(|error| panic!("goal run should succeed: {error}"));
454
455        assert_eq!(result.output, "done\n[GOAL_COMPLETE]");
456        assert_eq!(calls.load(Ordering::SeqCst), 2);
457        assert!(events.iter().any(|record| matches!(
458            record.event,
459            AgentStreamEvent::OutputRetry { retries: 1, .. }
460        )));
461        assert!(events.iter().any(|record| {
462            matches!(
463                &record.event,
464                AgentStreamEvent::Custom { event } if event.kind == GOAL_ITERATION_EVENT_KIND
465            )
466        }));
467        assert!(events.iter().any(|record| {
468            matches!(
469                &record.event,
470                AgentStreamEvent::Custom { event }
471                    if event.kind == GOAL_COMPLETE_EVENT_KIND
472                        && event.payload["reason"] == "verified"
473            )
474        }));
475    }
476}