Skip to content

Commit 2894baf

Browse files
Merge pull request #226 from ScriptedAlchemy/codex/hermes-parity-r10-outcomes
[codex] Track automation-applied outcome feedback
2 parents f9216ea + 33123a3 commit 2894baf

14 files changed

Lines changed: 1492 additions & 12 deletions

File tree

src/automation/artifact_payloads.rs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ use super::artifact_optimizer::{
1414
use super::artifact_policy::TaskArtifactPolicy;
1515
use super::artifact_refs::{automation_run_artifact_api, automation_run_artifacts_api};
1616
use super::backend::{AgentTaskKind, AgentTaskRequest, AgentTaskResponse};
17+
use super::outcomes::{
18+
outcome_eval_definitions, outcome_feedback_section, AutomationOutcomesSnapshot,
19+
};
1720
use super::run_ledger::{AutomationRunArtifactKind, AutomationRunLedgerRecord};
1821
use super::text::truncate_chars_for_prompt;
1922

@@ -26,6 +29,9 @@ pub(super) struct ArtifactPayloadContext<'a> {
2629
pub(super) request: &'a AgentTaskRequest,
2730
pub(super) response: &'a AgentTaskResponse,
2831
pub(super) record: &'a AutomationRunLedgerRecord,
32+
/// Post-approval outcomes of previously applied changes, when a snapshot
33+
/// has been recorded for this project.
34+
pub(super) outcomes: &'a AutomationOutcomesSnapshot,
2935
}
3036

3137
pub(super) struct GeneratedEvalPayloads {
@@ -35,6 +41,10 @@ pub(super) struct GeneratedEvalPayloads {
3541
pub(super) replay_results: Vec<Value>,
3642
pub(super) status: &'static str,
3743
pub(super) validation_decision: &'static str,
44+
/// Evals derived from real post-approval outcomes; tracked separately
45+
/// from the validation-replay definitions so the replay gate semantics
46+
/// stay unchanged.
47+
pub(super) outcome_definitions: Vec<Value>,
3848
}
3949

4050
pub(super) struct ImprovementGatePayload {
@@ -87,6 +97,7 @@ pub(super) fn feedback_payload(ctx: &ArtifactPayloadContext<'_>, trace_ref: &Val
8797
},
8898
"human": [],
8999
"model": validation_feedback_entries(ctx.record),
100+
"applied_change_outcomes": outcome_feedback_section(ctx.task, ctx.outcomes),
90101
})
91102
}
92103

@@ -101,6 +112,7 @@ pub(super) fn generated_eval_payloads(ctx: &ArtifactPayloadContext<'_>) -> Gener
101112
replay_results,
102113
status: generated_evals_status(count, runner_status),
103114
validation_decision: validation_gate_decision(ctx.record),
115+
outcome_definitions: outcome_eval_definitions(ctx.task, ctx.task_key, ctx.outcomes),
104116
}
105117
}
106118

@@ -130,6 +142,7 @@ pub(super) fn generated_evals_payload(
130142
"eval_count": evals.count,
131143
"accepted_count": ctx.record.accepted_count,
132144
"rejected_count": ctx.record.rejected_count,
145+
"outcome_eval_count": evals.outcome_definitions.len(),
133146
},
134147
"runner": {
135148
"type": "validation_replay",
@@ -160,6 +173,7 @@ pub(super) fn generated_evals_payload(
160173
"auto_apply": false,
161174
},
162175
"eval_definitions": evals.definitions.clone(),
176+
"outcome_eval_definitions": evals.outcome_definitions.clone(),
163177
"result_refs": [{
164178
"kind": "validation_report",
165179
"hash": validation_report_hash(ctx.record.validation_report.as_ref()),
@@ -383,3 +397,161 @@ pub(super) fn codex_handoff_payload(
383397
"tests_to_run": ctx.policy.handoff_tests(),
384398
})
385399
}
400+
401+
#[cfg(test)]
402+
#[allow(clippy::unwrap_used, clippy::expect_used)]
403+
mod tests {
404+
use super::super::artifact_policy::artifact_policy;
405+
use super::super::outcomes::{SkillOutcomeRecord, SkillOutcomeVerdict};
406+
use super::super::run_ledger::{AutomationRunStatus, AutomationTrigger};
407+
use super::*;
408+
409+
fn payload_fixture() -> (
410+
AgentTaskRequest,
411+
AgentTaskResponse,
412+
AutomationRunLedgerRecord,
413+
) {
414+
let request = AgentTaskRequest::new(
415+
"run-outcomes".to_string(),
416+
AgentTaskKind::SkillWriter,
417+
"propose skills".to_string(),
418+
Some("sha256:evidence".to_string()),
419+
json!({}),
420+
);
421+
let response = AgentTaskResponse {
422+
run_id: "run-outcomes".to_string(),
423+
task: AgentTaskKind::SkillWriter,
424+
output_text: "{\"skills\":[]}".to_string(),
425+
output_json: Some(json!({"skills": []})),
426+
model: None,
427+
input_tokens: None,
428+
output_tokens: None,
429+
};
430+
let record = AutomationRunLedgerRecord {
431+
schema_version: 2,
432+
run_id: "run-outcomes".to_string(),
433+
trigger: AutomationTrigger::ManualCli,
434+
task: AgentTaskKind::SkillWriter,
435+
task_key: Some("skill_writer".to_string()),
436+
backend: "codex_app_server".to_string(),
437+
host_mode: None,
438+
prompt_version: None,
439+
response_schema: None,
440+
strict_json: None,
441+
model: None,
442+
status: AutomationRunStatus::Succeeded,
443+
evidence_hash: Some("sha256:evidence".to_string()),
444+
input_hash: None,
445+
output_hash: None,
446+
proposed_ops: None,
447+
applied_ops: None,
448+
rejected_ops: None,
449+
validation_report: None,
450+
reviewed_count: 0,
451+
accepted_count: 0,
452+
rejected_count: 0,
453+
skipped_count: 0,
454+
fallback_status: None,
455+
error: None,
456+
error_classification: None,
457+
error_retryable: None,
458+
report_ref: None,
459+
artifacts: Vec::new(),
460+
started_at: "0".to_string(),
461+
completed_at: "0".to_string(),
462+
};
463+
(request, response, record)
464+
}
465+
466+
fn outcomes_snapshot() -> AutomationOutcomesSnapshot {
467+
AutomationOutcomesSnapshot {
468+
schema_version: 1,
469+
skills: vec![SkillOutcomeRecord {
470+
skill_id: "ignored-skill".to_string(),
471+
title: Some("Ignored skill".to_string()),
472+
approved_at: 1_000,
473+
days_since_approval: 30,
474+
views_since_approval: 2,
475+
uses_since_approval: 0,
476+
verdict: SkillOutcomeVerdict::Ignored,
477+
}],
478+
facts: Vec::new(),
479+
skills_refreshed_at: Some(2_000),
480+
facts_refreshed_at: None,
481+
}
482+
}
483+
484+
#[test]
485+
fn feedback_payload_includes_applied_change_outcomes() {
486+
let (request, response, record) = payload_fixture();
487+
let outcomes = outcomes_snapshot();
488+
let ctx = ArtifactPayloadContext {
489+
run_id: "run-outcomes",
490+
task: AgentTaskKind::SkillWriter,
491+
task_key: "skill_writer",
492+
prompt_version: "skill_writer:v1",
493+
policy: artifact_policy(AgentTaskKind::SkillWriter),
494+
request: &request,
495+
response: &response,
496+
record: &record,
497+
outcomes: &outcomes,
498+
};
499+
500+
let payload = feedback_payload(&ctx, &json!({"kind": "traces"}));
501+
let section = payload.get("applied_change_outcomes").unwrap();
502+
assert_eq!(section.get("status").unwrap(), &json!("available"));
503+
assert_eq!(
504+
section.pointer("/skill_verdicts/ignored").unwrap(),
505+
&json!(1)
506+
);
507+
assert_eq!(
508+
section.pointer("/skills/0/skill_id").unwrap(),
509+
&json!("ignored-skill")
510+
);
511+
512+
let evals = generated_eval_payloads(&ctx);
513+
assert_eq!(evals.outcome_definitions.len(), 1);
514+
let generated = generated_evals_payload(
515+
&ctx,
516+
(&json!({"kind": "traces"}), &json!({"kind": "feedback"})),
517+
&evals,
518+
);
519+
assert_eq!(
520+
generated.pointer("/summary/outcome_eval_count").unwrap(),
521+
&json!(1)
522+
);
523+
assert_eq!(
524+
generated
525+
.pointer("/outcome_eval_definitions/0/observed_outcome")
526+
.unwrap(),
527+
&json!("ignored")
528+
);
529+
// The validation-replay definitions must stay outcome-free so the
530+
// replay gate semantics are unchanged.
531+
assert_eq!(evals.count, 0);
532+
}
533+
534+
#[test]
535+
fn empty_outcomes_snapshot_reports_none_recorded() {
536+
let (request, response, record) = payload_fixture();
537+
let outcomes = AutomationOutcomesSnapshot::default();
538+
let ctx = ArtifactPayloadContext {
539+
run_id: "run-outcomes",
540+
task: AgentTaskKind::SessionReflector,
541+
task_key: "session_reflector",
542+
prompt_version: "session_reflector:v1",
543+
policy: artifact_policy(AgentTaskKind::SessionReflector),
544+
request: &request,
545+
response: &response,
546+
record: &record,
547+
outcomes: &outcomes,
548+
};
549+
550+
let payload = feedback_payload(&ctx, &json!({"kind": "traces"}));
551+
assert_eq!(
552+
payload.pointer("/applied_change_outcomes/status").unwrap(),
553+
&json!("no_outcomes_recorded")
554+
);
555+
assert!(generated_eval_payloads(&ctx).outcome_definitions.is_empty());
556+
}
557+
}

src/automation/artifacts.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use super::artifact_refs::artifact_ref;
1212
use super::backend::{
1313
prompt_version, task_key, AgentTaskKind, AgentTaskRequest, AgentTaskResponse,
1414
};
15+
use super::outcomes::load_outcomes_snapshot;
1516
use super::run_ledger::{
1617
write_run_artifact, AutomationRunArtifact, AutomationRunArtifactKind, AutomationRunLedgerRecord,
1718
};
@@ -74,6 +75,11 @@ pub(crate) async fn write_improvement_artifacts(
7475
let task_key = task_key(task);
7576
let prompt_version = prompt_version(task);
7677
let policy = artifact_policy(task);
78+
// A missing or unreadable outcomes snapshot must never block the run's
79+
// artifact trail; it only means no post-approval signal is available yet.
80+
let outcomes = load_outcomes_snapshot(dashboard_root)
81+
.await
82+
.unwrap_or_default();
7783
let ctx = ArtifactPayloadContext {
7884
run_id,
7985
task,
@@ -83,6 +89,7 @@ pub(crate) async fn write_improvement_artifacts(
8389
request,
8490
response,
8591
record,
92+
outcomes: &outcomes,
8693
};
8794
let mut writer = ImprovementArtifactWriter::new(dashboard_root, run_id, &created_at);
8895

src/automation/managed_skill_model.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ impl ManagedSkillDraft {
128128
checksum: String::new(),
129129
created_at: now,
130130
updated_at: now,
131+
approved_at: None,
131132
provenance: self.provenance,
132133
},
133134
body_markdown: self.body_markdown,
@@ -155,6 +156,11 @@ pub struct ManagedSkillMetadata {
155156
pub created_at: i64,
156157
#[serde(default)]
157158
pub updated_at: i64,
159+
/// When the skill last transitioned into `Active` (human approval).
160+
/// Anchors post-approval outcome tracking; absent for never-approved
161+
/// skills and records written before this field existed.
162+
#[serde(default, skip_serializing_if = "Option::is_none")]
163+
pub approved_at: Option<i64>,
158164
pub provenance: ManagedSkillProvenance,
159165
}
160166

@@ -214,6 +220,9 @@ impl ManagedSkill {
214220
pub fn set_state(&mut self, state: ManagedSkillState) {
215221
if self.metadata.state != state {
216222
self.metadata.state = state;
223+
if state == ManagedSkillState::Active {
224+
self.metadata.approved_at = Some(current_metadata_timestamp());
225+
}
217226
self.touch();
218227
}
219228
}

src/automation/managed_skills.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -430,17 +430,21 @@ async fn record_skill_patch(
430430

431431
pub async fn approve_managed_skill(profile_root: &Path, id: &str) -> Result<ManagedSkill> {
432432
let skill = load_managed_skill(profile_root, id).await?;
433-
let Some(pending) = skill.pending_update else {
434-
return set_managed_skill_state(profile_root, id, ManagedSkillState::Active).await;
433+
let approved = match skill.pending_update {
434+
None => set_managed_skill_state(profile_root, id, ManagedSkillState::Active).await?,
435+
Some(pending) => {
436+
let mut promoted = pending.into_skill();
437+
promoted.set_state(ManagedSkillState::Active);
438+
promoted.refresh_checksum();
439+
remove_pending_update(profile_root, id).await?;
440+
save_managed_skill(profile_root, &promoted).await?;
441+
record_skill_patch(profile_root, &promoted, "approve_staged_update".to_string())
442+
.await?;
443+
promoted
444+
}
435445
};
436-
437-
let mut promoted = pending.into_skill();
438-
promoted.set_state(ManagedSkillState::Active);
439-
promoted.refresh_checksum();
440-
remove_pending_update(profile_root, id).await?;
441-
save_managed_skill(profile_root, &promoted).await?;
442-
record_skill_patch(profile_root, &promoted, "approve_staged_update".to_string()).await?;
443-
Ok(promoted)
446+
super::skill_usage::record_skill_approval(profile_root, &approved).await?;
447+
Ok(approved)
444448
}
445449

446450
pub async fn disable_managed_skill(profile_root: &Path, id: &str) -> Result<ManagedSkill> {

src/automation/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod managed_skill_model;
1818
mod managed_skill_validation;
1919
pub mod managed_skills;
2020
pub mod memory_curator;
21+
pub mod outcomes;
2122
pub mod run_ledger;
2223
pub mod runner;
2324
pub mod scheduler;

0 commit comments

Comments
 (0)