Skip to content

Commit b28cff8

Browse files
committed
feat(subagent): enforce explicit summary output from subagents
When a subagent completes without providing a structured summary, the executor now requests an additional turn specifically asking for a summary. This ensures subagents always return properly formatted output for the orchestrator to process. The fix: - Adds has_summary_output() function to detect summary markers in output - Requests explicit summary turn if output doesn't contain summary markers - Uses SUMMARY_REQUEST_PROMPT constant with structured format requirements - Combines original response with summary in the final result - Properly tracks token usage across both turns This follows the Codex pattern of ensuring structured agent output and resolves the issue where subagents would terminate without providing feedback to the orchestrator.
1 parent f9ccb0e commit b28cff8

1 file changed

Lines changed: 156 additions & 2 deletions

File tree

cortex-engine/src/tools/handlers/subagent/executor.rs

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use tokio::sync::{RwLock, mpsc};
88
use tokio::time::timeout;
99
use uuid::Uuid;
1010

11-
use crate::agent::{AgentConfig, AgentEvent, Orchestrator, SandboxPolicy, TurnStatus};
11+
use crate::agent::{
12+
AgentConfig, AgentEvent, Orchestrator, OrchestratorTurnResult, SandboxPolicy, TurnStatus,
13+
};
1214
use crate::agents::{Agent, AgentRegistry};
1315
use crate::client::ModelClient;
1416
use crate::error::{CortexError, Result};
@@ -424,7 +426,7 @@ impl SubagentExecutor {
424426
);
425427

426428
// Execute with optional timeout (like OpenCode, no timeout by default)
427-
let turn_result = if let Some(timeout_duration) = config.effective_timeout() {
429+
let mut turn_result = if let Some(timeout_duration) = config.effective_timeout() {
428430
// With timeout
429431
match timeout(timeout_duration, orchestrator.process_turn(&mut turn_ctx)).await {
430432
Ok(result) => result,
@@ -439,6 +441,65 @@ impl SubagentExecutor {
439441
orchestrator.process_turn(&mut turn_ctx).await
440442
};
441443

444+
// MANDATORY: Request explicit summary if the response doesn't contain one
445+
// This ensures subagents always provide structured output for the orchestrator
446+
if let Ok(ref result) = turn_result {
447+
if result.status == TurnStatus::Completed && !has_summary_output(&result.response) {
448+
tracing::info!(
449+
session_id = %session_id,
450+
"Subagent output missing summary, requesting explicit summary turn"
451+
);
452+
453+
// Request a summary turn
454+
let summary_prompt = SUMMARY_REQUEST_PROMPT.to_string();
455+
456+
let summary_turn_id = session.turns_completed as u64 + 2;
457+
let mut summary_turn_ctx = crate::agent::TurnContext::new(
458+
summary_turn_id,
459+
session_id.clone(),
460+
summary_prompt,
461+
config.working_dir.clone(),
462+
);
463+
464+
// Execute summary turn with a reasonable timeout
465+
let summary_result = timeout(
466+
Duration::from_secs(60),
467+
orchestrator.process_turn(&mut summary_turn_ctx),
468+
)
469+
.await;
470+
471+
// Update turn_result with the summary if successful
472+
if let Ok(Ok(summary_response)) = summary_result {
473+
if summary_response.status == TurnStatus::Completed
474+
&& !summary_response.response.is_empty()
475+
{
476+
tracing::info!(
477+
session_id = %session_id,
478+
"Received explicit summary from subagent"
479+
);
480+
// Combine original response with summary
481+
turn_result = Ok(OrchestratorTurnResult {
482+
turn_id: result.turn_id,
483+
status: TurnStatus::Completed,
484+
response: format!(
485+
"{}\n\n{}",
486+
result.response, summary_response.response
487+
),
488+
tool_calls: result.tool_calls.clone(),
489+
token_usage: result.token_usage.clone(),
490+
duration: result.duration,
491+
});
492+
// Update token counts
493+
turn_ctx.tokens.input_tokens += summary_turn_ctx.tokens.input_tokens;
494+
turn_ctx.tokens.output_tokens += summary_turn_ctx.tokens.output_tokens;
495+
turn_ctx.tokens.cached_tokens += summary_turn_ctx.tokens.cached_tokens;
496+
turn_ctx.tokens.reasoning_tokens +=
497+
summary_turn_ctx.tokens.reasoning_tokens;
498+
}
499+
}
500+
}
501+
}
502+
442503
// CRITICAL: Drop orchestrator to close the event channel
443504
// This allows event_handler to exit its recv() loop
444505
// Without this, we'd have a deadlock:
@@ -646,6 +707,56 @@ pub struct SubagentTypeInfo {
646707
}
647708

648709
/// Extract file path from tool output.
710+
/// Prompt used to request an explicit summary from a subagent when none was provided.
711+
/// This follows the Codex pattern of ensuring structured output from agents.
712+
const SUMMARY_REQUEST_PROMPT: &str = r#"You have completed your work but did not provide a summary. Please provide a final summary NOW using EXACTLY this format:
713+
714+
## Summary for Orchestrator
715+
716+
### Tasks Completed
717+
- [List each task you completed with brief outcome]
718+
719+
### Key Findings/Changes
720+
- [Main discoveries or modifications made]
721+
722+
### Files Modified (if any)
723+
- [List of files with type of change]
724+
725+
### Recommendations (if applicable)
726+
- [Any follow-up actions or suggestions]
727+
728+
### Status: COMPLETED
729+
730+
DO NOT use any tools. Just provide the summary based on the work you have already done."#;
731+
732+
/// Check if the response contains a proper summary for the orchestrator.
733+
/// Returns true if summary markers are present, false otherwise.
734+
fn has_summary_output(response: &str) -> bool {
735+
// Empty responses definitely don't have a summary
736+
if response.trim().is_empty() {
737+
return false;
738+
}
739+
740+
// Check for key summary markers that indicate structured output
741+
let summary_markers = [
742+
"## Summary for Orchestrator",
743+
"### Tasks Completed",
744+
"### Key Findings",
745+
"### Status: COMPLETED",
746+
"Status: COMPLETED",
747+
// Also accept some variations
748+
"## Summary",
749+
"### Summary",
750+
"## Final Summary",
751+
"### Final Summary",
752+
];
753+
754+
let response_lower = response.to_lowercase();
755+
summary_markers
756+
.iter()
757+
.any(|marker| response_lower.contains(&marker.to_lowercase()))
758+
}
759+
649760
fn extract_file_path(output: &str) -> Option<String> {
650761
// Try to extract path from common patterns
651762
// "Created file: path/to/file"
@@ -697,6 +808,49 @@ mod tests {
697808
assert_eq!(extract_file_path("No path here"), None);
698809
}
699810

811+
#[test]
812+
fn test_has_summary_output_with_proper_summary() {
813+
let response_with_summary = r#"
814+
## Summary for Orchestrator
815+
816+
### Tasks Completed
817+
- Analyzed the codebase structure
818+
819+
### Key Findings
820+
- Found 10 modules
821+
822+
### Status: COMPLETED
823+
"#;
824+
assert!(has_summary_output(response_with_summary));
825+
}
826+
827+
#[test]
828+
fn test_has_summary_output_with_variation() {
829+
// Test case-insensitive matching
830+
let response = "## summary\nSome content here";
831+
assert!(has_summary_output(response));
832+
833+
// Test "Status: COMPLETED" alone
834+
let response2 = "Work done.\n\nStatus: COMPLETED";
835+
assert!(has_summary_output(response2));
836+
}
837+
838+
#[test]
839+
fn test_has_summary_output_empty() {
840+
assert!(!has_summary_output(""));
841+
assert!(!has_summary_output(" "));
842+
assert!(!has_summary_output("\n\n"));
843+
}
844+
845+
#[test]
846+
fn test_has_summary_output_no_markers() {
847+
let response_without_summary = "I analyzed the code and found some issues.";
848+
assert!(!has_summary_output(response_without_summary));
849+
850+
let response_partial = "Here are some findings:\n- Item 1\n- Item 2";
851+
assert!(!has_summary_output(response_partial));
852+
}
853+
700854
#[test]
701855
fn test_subagent_type_info() {
702856
let executor = SubagentExecutor::new(

0 commit comments

Comments
 (0)