Skip to content

Commit 429d5fa

Browse files
Merge pull request #220 from ScriptedAlchemy/codex/hermes-parity-r4-session-replay
[codex] Add session-replay evidence for automation
2 parents dc1622c + d65584e commit 429d5fa

12 files changed

Lines changed: 1171 additions & 15 deletions

File tree

src/automation/artifact_payloads.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub(super) fn traces_payload(ctx: &ArtifactPayloadContext<'_>) -> Value {
7171
"response_schema": ctx.request.contract.response_schema,
7272
"strict_json": ctx.request.contract.strict_json,
7373
"evidence_hash": ctx.record.evidence_hash,
74+
"evidence_mode": context_evidence_mode(&ctx.request.context),
7475
"input_hash": ctx.record.input_hash,
7576
"output_hash": ctx.record.output_hash,
7677
"context_keys": ctx.request.context.as_object()
@@ -79,6 +80,22 @@ pub(super) fn traces_payload(ctx: &ArtifactPayloadContext<'_>) -> Value {
7980
})
8081
}
8182

83+
/// Extracts the `evidence_mode` label from whichever evidence object the task
84+
/// placed in the request context (e.g. `session_reflection_evidence` or
85+
/// `skill_writer_evidence`), so traces distinguish session-replay-backed runs
86+
/// from grep-only runs. Null for tasks without a mode label.
87+
fn context_evidence_mode(context: &Value) -> Value {
88+
context
89+
.as_object()
90+
.and_then(|object| {
91+
object
92+
.values()
93+
.find_map(|value| value.get("evidence_mode").filter(|mode| mode.is_string()))
94+
})
95+
.cloned()
96+
.unwrap_or(Value::Null)
97+
}
98+
8299
pub(super) fn feedback_payload(ctx: &ArtifactPayloadContext<'_>, trace_ref: &Value) -> Value {
83100
json!({
84101
"schema_version": 1,

src/automation/runner.rs

Lines changed: 205 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ use crate::global_db::GlobalDb;
3434
use crate::sessions::cursor::{
3535
resolve_hermes_profile_session_db_readonly, HermesProfileDbReadOnly,
3636
};
37-
use crate::sessions::lcm::{LcmGrepRequest, LcmGrepSort, LcmScope};
37+
use crate::sessions::lcm::{
38+
LcmGrepRequest, LcmGrepSort, LcmScope, LcmSessionReplayRequest, LcmSessionReplaySlice,
39+
};
3840
use crate::tracedecay::{current_timestamp, TraceDecay};
3941

4042
pub use super::memory_curator::{
@@ -43,6 +45,14 @@ pub use super::memory_curator::{
4345

4446
const SKILL_ANALYTICS_IMPORT_LIMIT: usize = 2_000;
4547

48+
/// Bounds for the session-replay evidence channel. Worst case per session is
49+
/// `(4 + 4) * 500 + 3 * 700 = 6_100` snippet chars, so the default three
50+
/// sessions stay under ~5k tokens alongside the grep hits.
51+
const SESSION_REPLAY_HEAD_TURNS: usize = 4;
52+
const SESSION_REPLAY_TAIL_TURNS: usize = 4;
53+
const SESSION_REPLAY_SNIPPET_CHARS: usize = 500;
54+
const SESSION_REPLAY_SUMMARY_NODES: usize = 3;
55+
const SESSION_REPLAY_SUMMARY_CHARS: usize = 700;
4656
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4757
pub struct SessionReflectorAutomationOptions {
4858
#[serde(default)]
@@ -65,6 +75,14 @@ pub struct SessionReflectorAutomationOptions {
6575
pub include_summaries: bool,
6676
#[serde(default = "default_session_evidence_limit")]
6777
pub evidence_limit: usize,
78+
/// When true, include bounded turn-ordered slices of recently active
79+
/// sessions as a primary evidence channel alongside the keyword grep.
80+
#[serde(default = "default_include_recent_sessions")]
81+
pub include_recent_sessions: bool,
82+
/// How many recently active sessions to replay when `session_id` is not
83+
/// explicitly set.
84+
#[serde(default = "default_recent_sessions_limit")]
85+
pub recent_sessions_limit: usize,
6886
#[serde(default = "default_lcm_grep_sort")]
6987
pub sort: LcmGrepSort,
7088
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -90,6 +108,8 @@ impl Default for SessionReflectorAutomationOptions {
90108
session_id: None,
91109
include_summaries: default_include_summaries(),
92110
evidence_limit: default_session_evidence_limit(),
111+
include_recent_sessions: default_include_recent_sessions(),
112+
recent_sessions_limit: default_recent_sessions_limit(),
93113
sort: default_lcm_grep_sort(),
94114
source: None,
95115
role: None,
@@ -124,6 +144,13 @@ pub struct SkillWriterAutomationOptions {
124144
pub query: String,
125145
#[serde(default = "default_skill_writer_evidence_limit")]
126146
pub evidence_limit: usize,
147+
/// When true, include bounded turn-ordered slices of recently active
148+
/// sessions as a primary evidence channel alongside the keyword grep.
149+
#[serde(default = "default_include_recent_sessions")]
150+
pub include_recent_sessions: bool,
151+
/// How many recently active sessions to replay.
152+
#[serde(default = "default_recent_sessions_limit")]
153+
pub recent_sessions_limit: usize,
127154
#[serde(default, skip_serializing_if = "Option::is_none")]
128155
pub profile_root: Option<PathBuf>,
129156
}
@@ -138,6 +165,8 @@ impl Default for SkillWriterAutomationOptions {
138165
provider: default_skill_writer_provider(),
139166
query: default_skill_writer_query(),
140167
evidence_limit: default_skill_writer_evidence_limit(),
168+
include_recent_sessions: default_include_recent_sessions(),
169+
recent_sessions_limit: default_recent_sessions_limit(),
141170
profile_root: None,
142171
}
143172
}
@@ -684,7 +713,28 @@ async fn build_session_reflector_evidence(
684713
.map_err(|e| TraceDecayError::Config {
685714
message: format!("failed to build session reflection evidence: {e}"),
686715
})?;
716+
let recent_session_slices = if options.include_recent_sessions
717+
&& session_reflector_replay_allowed(
718+
options.scope,
719+
session_id.as_deref(),
720+
source.as_deref(),
721+
role.as_deref(),
722+
options.start_time,
723+
options.end_time,
724+
) {
725+
recent_session_replay_evidence(
726+
&lcm_db,
727+
&provider,
728+
session_id.as_deref(),
729+
options.recent_sessions_limit,
730+
"session_reflector",
731+
)
732+
.await?
733+
} else {
734+
None
735+
};
687736
let evidence = json!({
737+
"evidence_mode": evidence_mode_label(recent_session_slices.is_some()),
688738
"storage_scope": storage_scope,
689739
"hermes_home": options.hermes_home.as_ref().map(|path| path.display().to_string()),
690740
"provider": provider,
@@ -697,14 +747,19 @@ async fn build_session_reflector_evidence(
697747
"role": role,
698748
"start_time": options.start_time,
699749
"end_time": options.end_time,
750+
"recent_session_slices": recent_session_slices,
700751
"hits": hits,
701752
});
702753
let evidence_hash = Some(sha256_json(&evidence));
703-
if evidence
754+
let has_grep_hits = evidence
704755
.get("hits")
705756
.and_then(Value::as_array)
706-
.is_none_or(Vec::is_empty)
707-
{
757+
.is_some_and(|hits| !hits.is_empty());
758+
let has_replay_sessions = evidence
759+
.pointer("/recent_session_slices/sessions")
760+
.and_then(Value::as_array)
761+
.is_some_and(|sessions| !sessions.is_empty());
762+
if !has_grep_hits && !has_replay_sessions {
708763
return Ok(SessionReflectorEvidenceOutcome::Skipped {
709764
reason: "no_session_evidence",
710765
evidence_hash,
@@ -772,6 +827,18 @@ async fn build_skill_writer_evidence(
772827
.map_err(|e| TraceDecayError::Config {
773828
message: format!("failed to build skill writer evidence: {e}"),
774829
})?;
830+
let recent_session_slices = if options.include_recent_sessions {
831+
recent_session_replay_evidence(
832+
&lcm_db,
833+
&provider,
834+
None,
835+
options.recent_sessions_limit,
836+
"skill_writer",
837+
)
838+
.await?
839+
} else {
840+
None
841+
};
775842
let existing_skills = list_managed_skills(&profile_root).await?;
776843
let global_db = GlobalDb::open().await;
777844
ingest_project_analytics_events(
@@ -805,10 +872,12 @@ async fn build_skill_writer_evidence(
805872
&underused_tool_families,
806873
);
807874
let evidence = json!({
875+
"evidence_mode": evidence_mode_label(recent_session_slices.is_some()),
808876
"storage_scope": storage_scope,
809877
"hermes_home": options.hermes_home.as_ref().map(|path| path.display().to_string()),
810878
"provider": provider,
811879
"query": query,
880+
"recent_session_slices": recent_session_slices,
812881
"hits": hits,
813882
"skill_usage_summaries": skill_usage_summaries,
814883
"stale_recommendations": stale_recommendations,
@@ -834,11 +903,15 @@ async fn build_skill_writer_evidence(
834903
.collect::<Vec<_>>(),
835904
});
836905
let evidence_hash = Some(sha256_json(&evidence));
837-
if evidence
906+
let has_grep_hits = evidence
838907
.get("hits")
839908
.and_then(Value::as_array)
840-
.is_none_or(Vec::is_empty)
841-
{
909+
.is_some_and(|hits| !hits.is_empty());
910+
let has_replay_sessions = evidence
911+
.pointer("/recent_session_slices/sessions")
912+
.and_then(Value::as_array)
913+
.is_some_and(|sessions| !sessions.is_empty());
914+
if !has_grep_hits && !has_replay_sessions {
842915
return Ok(SkillWriterEvidenceOutcome::Skipped {
843916
reason: "no_skill_writer_evidence",
844917
evidence_hash,
@@ -1236,6 +1309,7 @@ fn build_combined_review_prompt(reflector_evidence: &Value, skill_evidence: &Val
12361309
fn build_session_reflector_prompt(evidence: &Value) -> String {
12371310
const POLICY: &str = concat!(
12381311
"Review these bounded TraceDecay session snippets and propose only durable memory facts.\n",
1312+
"Evidence has two channels: recent_session_slices holds turn-ordered head/tail turns and summary nodes replayed from recently active sessions, and hits holds keyword search matches; both are citable.\n",
12391313
"\n",
12401314
"Signals worth capturing (any one is enough):\n",
12411315
"- The user revealed durable preferences, persona, expectations, or ways they want the agent to operate.\n",
@@ -1262,6 +1336,7 @@ fn build_session_reflector_prompt(evidence: &Value) -> String {
12621336
fn build_skill_writer_prompt(evidence: &Value) -> String {
12631337
const POLICY: &str = concat!(
12641338
"Review these bounded TraceDecay session snippets and propose only reusable managed skills for repeated workflows, corrections, or tool-use patterns.\n",
1339+
"Evidence has two channels: recent_session_slices holds turn-ordered head/tail turns and summary nodes replayed from recently active sessions, and hits holds keyword search matches.\n",
12651340
"\n",
12661341
"Target shape of the skill library: CLASS-LEVEL umbrella skills, each with a rich body and support files for session-specific detail — not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.\n",
12671342
"\n",
@@ -1302,6 +1377,121 @@ fn normalized_non_empty(value: &str) -> Option<String> {
13021377
}
13031378
}
13041379

1380+
struct ReplaySessionTarget {
1381+
provider: String,
1382+
session_id: String,
1383+
}
1384+
1385+
/// Builds the "recent completed sessions" replay evidence channel: bounded
1386+
/// turn-ordered head/tail slices plus top summary-DAG nodes for the last N
1387+
/// recently active sessions (or one explicitly requested session).
1388+
///
1389+
/// Returns `None` when no session has any raw messages, so callers can fall
1390+
/// back to grep-only evidence.
1391+
async fn recent_session_replay_evidence(
1392+
lcm_db: &GlobalDb,
1393+
provider: &str,
1394+
explicit_session_id: Option<&str>,
1395+
sessions_limit: usize,
1396+
task_name: &str,
1397+
) -> Result<Option<Value>> {
1398+
let sessions_limit = sessions_limit.clamp(1, 10);
1399+
let provider_filter = (provider != "all").then_some(provider);
1400+
let replay_error = |e: crate::sessions::lcm::LcmError| TraceDecayError::Config {
1401+
message: format!("failed to build {task_name} session replay evidence: {e}"),
1402+
};
1403+
let (session_selection, targets) = if let Some(session_id) = explicit_session_id {
1404+
let providers = match provider_filter {
1405+
Some(provider) => vec![provider.to_string()],
1406+
None => lcm_db
1407+
.lcm_session_providers(session_id)
1408+
.await
1409+
.map_err(replay_error)?,
1410+
};
1411+
let targets: Vec<ReplaySessionTarget> = providers
1412+
.into_iter()
1413+
.map(|provider| ReplaySessionTarget {
1414+
provider,
1415+
session_id: session_id.to_string(),
1416+
})
1417+
.collect();
1418+
("explicit_session_id", targets)
1419+
} else {
1420+
let targets: Vec<ReplaySessionTarget> = lcm_db
1421+
.lcm_recent_sessions(provider_filter, sessions_limit)
1422+
.await
1423+
.map_err(replay_error)?
1424+
.into_iter()
1425+
.map(|session| ReplaySessionTarget {
1426+
provider: session.provider,
1427+
session_id: session.session_id,
1428+
})
1429+
.collect();
1430+
("recent_activity", targets)
1431+
};
1432+
1433+
let mut sessions: Vec<LcmSessionReplaySlice> = Vec::new();
1434+
for target in targets {
1435+
let slice = lcm_db
1436+
.lcm_session_replay_slice(&LcmSessionReplayRequest {
1437+
provider: target.provider,
1438+
session_id: target.session_id,
1439+
head_limit: SESSION_REPLAY_HEAD_TURNS,
1440+
tail_limit: SESSION_REPLAY_TAIL_TURNS,
1441+
max_snippet_chars: SESSION_REPLAY_SNIPPET_CHARS,
1442+
summary_limit: SESSION_REPLAY_SUMMARY_NODES,
1443+
max_summary_chars: SESSION_REPLAY_SUMMARY_CHARS,
1444+
})
1445+
.await
1446+
.map_err(replay_error)?;
1447+
if slice.total_messages == 0 && slice.summary_nodes.is_empty() {
1448+
continue;
1449+
}
1450+
sessions.push(slice);
1451+
}
1452+
if sessions.is_empty() {
1453+
return Ok(None);
1454+
}
1455+
Ok(Some(json!({
1456+
"mode": "recent_sessions",
1457+
"session_selection": session_selection,
1458+
"sessions_limit": sessions_limit,
1459+
"bounds": {
1460+
"head_turns": SESSION_REPLAY_HEAD_TURNS,
1461+
"tail_turns": SESSION_REPLAY_TAIL_TURNS,
1462+
"snippet_chars": SESSION_REPLAY_SNIPPET_CHARS,
1463+
"summary_nodes": SESSION_REPLAY_SUMMARY_NODES,
1464+
"summary_chars": SESSION_REPLAY_SUMMARY_CHARS,
1465+
},
1466+
"sessions": sessions,
1467+
})))
1468+
}
1469+
1470+
/// Names the evidence channels actually present so run artifacts can
1471+
/// distinguish replay-backed runs from grep-only runs.
1472+
fn evidence_mode_label(has_replay: bool) -> &'static str {
1473+
if has_replay {
1474+
"session_replay_with_grep"
1475+
} else {
1476+
"grep_only"
1477+
}
1478+
}
1479+
1480+
fn session_reflector_replay_allowed(
1481+
scope: LcmScope,
1482+
session_id: Option<&str>,
1483+
source: Option<&str>,
1484+
role: Option<&str>,
1485+
start_time: Option<i64>,
1486+
end_time: Option<i64>,
1487+
) -> bool {
1488+
if source.is_some() || role.is_some() || start_time.is_some() || end_time.is_some() {
1489+
return false;
1490+
}
1491+
1492+
matches!(scope, LcmScope::All) || session_id.is_some()
1493+
}
1494+
13051495
/// Resolves the LCM sessions database for an automation task, reporting
13061496
/// `NotIngested` when the store does not exist yet so callers can skip
13071497
/// without re-checking the path.
@@ -1372,6 +1562,14 @@ fn default_session_evidence_limit() -> usize {
13721562
20
13731563
}
13741564

1565+
fn default_include_recent_sessions() -> bool {
1566+
true
1567+
}
1568+
1569+
fn default_recent_sessions_limit() -> usize {
1570+
3
1571+
}
1572+
13751573
fn default_skill_writer_query() -> String {
13761574
"workflow correction repeated skill tool pattern".to_string()
13771575
}

0 commit comments

Comments
 (0)