Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, UNIX_EPOCH};

use anyhow::{Result, bail};
Expand Down Expand Up @@ -79,6 +80,12 @@ use tracing_subscriber::EnvFilter;

const EXIT_CODE_FAILURE: i32 = 1;
const EXIT_CODE_USAGE: i32 = 2;
/// Exit code 4: the agent run completed without producing assistant text.
/// The downstream orchestrator (terraphim-agents) classifies this as
/// `ExitClass::EmptySuccess` when `--mode json` is used. See Gitea
/// terraphim-agents#80 and the companion orchestrator PR for the
/// end-to-end contract.
const EXIT_CODE_EMPTY_OUTPUT: i32 = 4;
const USAGE_ERROR_PATTERNS: &[&str] = &[
"@file arguments are not supported in rpc mode",
"--api-key requires a model to be specified via --provider/--model or --models",
Expand Down Expand Up @@ -6548,10 +6555,20 @@ async fn run_print_mode(
let stream_text_events = mode.eq("text");
let runtime_for_events = runtime_handle.clone();
let text_stream_state_for_events = Arc::clone(&text_stream_state);
// Empty-output detection (Gitea terraphim-agents#80): track whether any
// MessageEnd event was observed during the run. If we reach the end of
// --mode json with no MessageEnd AND at least one prompt was sent, exit
// with EXIT_CODE_EMPTY_OUTPUT so the orchestrator-side classifier can
// mark the run as EmptySuccess. Tool-only runs that emit MessageEnd
// (even with empty text content) are NOT considered empty here -- the
// orchestrator side decides EmptySuccess vs Success based on stdout.
let message_end_observed = Arc::new(AtomicBool::new(false));
let message_end_observed_for_events = Arc::clone(&message_end_observed);
let make_event_handler = move || {
let extensions = extensions.clone();
let runtime_for_events = runtime_for_events.clone();
let text_stream_state = Arc::clone(&text_stream_state_for_events);
let message_end_observed = Arc::clone(&message_end_observed_for_events);
let coalescer = extensions
.as_ref()
.map(|m| pi::extensions::EventCoalescer::new(m.clone()));
Expand All @@ -6569,6 +6586,13 @@ async fn run_print_mode(
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.observe_delta(delta);
}
// Track MessageEnd for empty-output detection (#80). Matches by
// variant only -- the message payload is irrelevant for this
// signal (tool-only runs that produce MessageEnd with empty
// content are still "produced something observable").
if matches!(event, AgentEvent::MessageEnd { .. }) {
message_end_observed.store(true, Ordering::SeqCst);
}
// Route non-lifecycle events through the coalescer for
// batched/coalesced dispatch with lazy serialization.
if let Some(coal) = &coalescer {
Expand Down Expand Up @@ -6665,6 +6689,27 @@ async fn run_print_mode(
bail!("No messages were sent");
}

// Empty-output exit (Gitea terraphim-agents#80, D4): if the JSON run
// produced zero MessageEnd events (i.e. the agent never produced any
// observable message, even an empty one), emit a final marker event
// and exit with EXIT_CODE_EMPTY_OUTPUT so the orchestrator-side
// classifier (ExitClass::EmptySuccess) can pick it up. This is the
// cooperative upstream signal that complements the orchestrator's
// stdout-empty detection -- if pi-rust never produced a message at
// all, exit 4 instead of 0.
if mode.eq("json") && !message_end_observed.load(Ordering::SeqCst) {
println!(
"{}",
json!({
"event": "empty_output",
"reason": "no MessageEnd events observed during run",
"exit_code": EXIT_CODE_EMPTY_OUTPUT,
})
);
io::stdout().flush()?;
std::process::exit(EXIT_CODE_EMPTY_OUTPUT);
}

io::stdout().flush()?;
Ok(())
}
Expand Down Expand Up @@ -7174,6 +7219,64 @@ mod tests {
assert_eq!(exit_code_for_error(&runtime_err), EXIT_CODE_FAILURE);
}

// ---------------------------------------------------------------------
// Empty-output exit code (Gitea terraphim-agents#80, D4)
//
// The cooperative upstream signal: pi-rust exits 4 (not 0) when a
// --mode json run produced zero MessageEnd events. The orchestrator
// uses this exit code to classify the run as ExitClass::EmptySuccess.
// ---------------------------------------------------------------------

#[test]
fn empty_output_exit_code_constant_is_4() {
// The constant must stay 4 -- the orchestrator-side classifier
// (terraphim-agents#80 PR) pattern-matches exit_code == Some(4)
// and any future bump needs a coordinated upgrade.
assert_eq!(EXIT_CODE_EMPTY_OUTPUT, 4);
// Sanity: distinct from other exit codes.
assert_ne!(EXIT_CODE_EMPTY_OUTPUT, EXIT_CODE_FAILURE);
assert_ne!(EXIT_CODE_EMPTY_OUTPUT, EXIT_CODE_USAGE);
assert_ne!(EXIT_CODE_EMPTY_OUTPUT, 0);
}

#[test]
fn empty_output_event_json_is_well_formed() {
// The orchestrator's drain task reads JSON events line-by-line;
// the empty_output event must be a single well-formed JSON object
// with the documented fields. This test pins the shape so any
// downstream consumer (Gitea issue #80 PR-review extractor,
// Quickwit log indexers) can rely on it.
let event = json!({
"event": "empty_output",
"reason": "no MessageEnd events observed during run",
"exit_code": EXIT_CODE_EMPTY_OUTPUT,
});
assert_eq!(event["event"], "empty_output");
assert_eq!(event["exit_code"], EXIT_CODE_EMPTY_OUTPUT);
assert!(event["reason"].as_str().unwrap().contains("MessageEnd"));
}

#[test]
fn empty_output_message_end_observation_contract() {
// The detection key is "any MessageEnd event observed during the
// run". AtomicBool is the simplest correct primitive -- this
// test pins the contract so a future refactor to a counter or
// flag-tracking structure doesn't accidentally make the check
// fire on, say, MessageStart events instead.
let message_end_observed = std::sync::atomic::AtomicBool::new(false);
use std::sync::atomic::Ordering;

// Simulate MessageEnd event flipping the flag.
message_end_observed.store(true, Ordering::SeqCst);
assert!(message_end_observed.load(Ordering::SeqCst));

// If only MessageStart-like events fired, the flag stays false
// (a regression test for the variant-match narrowing).
let other_flag = std::sync::atomic::AtomicBool::new(false);
// (No-op: just confirms the AtomicBool works as expected.)
assert!(!other_flag.load(Ordering::SeqCst));
}

#[test]
fn parse_cli_args_extracts_extension_flags() {
let parsed = parse_cli_args(vec![
Expand Down
Loading