1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0
mod arise;
pub(crate) use arise::PoolProviderResolution;
mod background;
mod d2skill;
mod erl;
mod outcomes;
mod preferences;
mod rl;
mod skill_commands;
mod trust;
#[cfg(test)]
mod tests;
use super::{Agent, Channel};
/// Accumulated skill outcome for a single tool result within a batch.
/// Used by `flush_skill_outcomes` to collapse N per-tool-result `record_skill_outcomes`
/// calls into a single pass after the tool batch completes.
pub(crate) struct PendingSkillOutcome {
pub outcome: String,
pub error_context: Option<String>,
pub outcome_detail: Option<String>,
}
impl<C: Channel> Agent<C> {
pub(crate) fn is_learning_enabled(&self) -> bool {
self.services.learning_engine.is_enabled()
}
async fn is_skill_trusted_for_learning(&self, skill_name: &str) -> bool {
let Some(memory) = &self.services.memory.persistence.memory else {
return true;
};
let Ok(Some(row)) = memory.sqlite().load_skill_trust(skill_name).await else {
return true; // no trust record = local skill = trusted
};
matches!(
row.trust_level,
zeph_common::SkillTrustLevel::Trusted | zeph_common::SkillTrustLevel::Verified
)
}
pub(crate) async fn record_skill_outcomes(
&mut self,
outcome: &str,
error_context: Option<&str>,
outcome_detail: Option<&str>,
) {
if self.services.skill.active_skill_names.is_empty() {
return;
}
let Some(memory) = &self.services.memory.persistence.memory else {
return;
};
let batch_result = tokio::time::timeout(
std::time::Duration::from_secs(5),
memory.sqlite().record_skill_outcomes_batch(
&self.services.skill.active_skill_names,
self.services.memory.persistence.conversation_id,
outcome,
error_context,
outcome_detail,
),
)
.await;
match batch_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!("failed to record skill outcomes: {e:#}");
}
Err(_) => {
tracing::warn!("record_skill_outcomes: timed out after 5s");
return;
}
}
if outcome != "success" {
for name in &self.services.skill.active_skill_names {
self.check_rollback(name).await;
}
}
let names: Vec<String> = self.services.skill.active_skill_names.clone();
for name in &names {
self.check_trust_transition(name).await;
}
self.update_skill_confidence_metrics().await;
// SkillOrchestra RL routing head update (fire-and-forget).
self.spawn_rl_head_update(outcome);
// ARISE + STEM + ERL background tasks (fire-and-forget, never block response).
self.spawn_stem_detection(outcome);
if outcome == "success" {
for name in &names {
self.spawn_arise_trace_improvement(name);
self.spawn_erl_reflection(name);
}
}
}
/// Flush all accumulated skill outcomes from a tool batch in a single pass.
///
/// Replaces N sequential `record_skill_outcomes` calls (one per tool result) with one
/// batched write + one rollback/trust check per skill. This eliminates the N×M×13
/// sequential `SQLite` awaits that stalled the agent loop (#2770).
///
/// # Dominant outcome for RL/ARISE/ERL signals
///
/// Mixed batches (successes + failures) are collapsed to a single "dominant" outcome:
/// any failure in the batch → `"tool_failure"`, otherwise `"success"`. This trades
/// per-tool RL signal granularity for loop latency — acceptable because the RL head
/// operates at turn granularity anyway.
pub(crate) async fn flush_skill_outcomes(&mut self, outcomes: Vec<PendingSkillOutcome>) {
if outcomes.is_empty() || self.services.skill.active_skill_names.is_empty() {
return;
}
let Some(memory) = &self.services.memory.persistence.memory else {
return;
};
// Batch-insert each outcome entry (one DB call per entry, but only once per tool —
// not once per tool × skill as was the case before batching).
for o in &outcomes {
let batch_result = tokio::time::timeout(
std::time::Duration::from_secs(5),
memory.sqlite().record_skill_outcomes_batch(
&self.services.skill.active_skill_names,
self.services.memory.persistence.conversation_id,
&o.outcome,
o.error_context.as_deref(),
o.outcome_detail.as_deref(),
),
)
.await;
match batch_result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("failed to record skill outcomes: {e:#}"),
Err(_) => {
tracing::warn!("record_skill_outcomes: timed out after 5s");
break;
}
}
}
let had_failure = outcomes.iter().any(|o| o.outcome != "success");
// Run rollback + trust checks ONCE per skill (not once per tool result).
if had_failure {
for name in &self.services.skill.active_skill_names.clone() {
self.check_rollback(name).await;
}
}
let names: Vec<String> = self.services.skill.active_skill_names.clone();
for name in &names {
self.check_trust_transition(name).await;
}
// update_skill_confidence_metrics does one SQLite read + one watch::Sender::send_modify.
// Wrap with a timeout to prevent stalling the loop if SQLite is slow.
if let Err(_elapsed) = tokio::time::timeout(
std::time::Duration::from_secs(2),
self.update_skill_confidence_metrics(),
)
.await
{
tracing::warn!("update_skill_confidence_metrics timed out after 2s");
}
// Determine dominant outcome: any failure → "tool_failure", else "success".
let dominant = if had_failure {
"tool_failure"
} else {
"success"
};
self.spawn_rl_head_update(dominant);
self.spawn_stem_detection(dominant);
if !had_failure {
for name in &names {
self.spawn_arise_trace_improvement(name);
self.spawn_erl_reflection(name);
}
}
}
/// Returns true and spawns `fut` when the learning task cap has not been reached.
///
/// When at capacity, logs a debug message and returns false (no abort of existing tasks).
pub(super) fn try_spawn_learning_task(
&mut self,
fut: impl std::future::Future<Output = ()> + Send + 'static,
) -> bool {
if self.services.learning_engine.learning_tasks.len()
>= crate::agent::learning_engine::MAX_LEARNING_TASKS
{
tracing::debug!(
"learning_tasks at capacity ({}), skipping spawn",
crate::agent::learning_engine::MAX_LEARNING_TASKS
);
return false;
}
self.services.learning_engine.learning_tasks.spawn(fut);
true
}
/// Fetches skill outcome stats and applies them to `metrics.skill_confidence`. Used by
/// callers that don't already have the stats in scope (`record_skill_outcomes`,
/// `flush_skill_outcomes`). `rebuild_system_prompt` instead calls
/// [`Self::apply_skill_confidence_metrics`] directly with its own single per-turn fetch,
/// to avoid a second `load_skill_outcome_stats()` query on that hot path (#6266).
pub(crate) async fn update_skill_confidence_metrics(&self) {
let Some(memory) = &self.services.memory.persistence.memory else {
return;
};
let Ok(stats) = memory.sqlite().load_skill_outcome_stats().await else {
return;
};
self.apply_skill_confidence_metrics(&stats);
}
/// Applies already-fetched skill outcome stats to `metrics.skill_confidence`, without
/// querying `SQLite`. Pure data transform, split out of
/// [`Self::update_skill_confidence_metrics`] so `rebuild_system_prompt` can reuse its own
/// per-turn `load_skill_outcome_stats()` fetch instead of triggering a second query (#6266).
pub(crate) fn apply_skill_confidence_metrics(
&self,
stats: &[zeph_memory::store::SkillMetricsRow],
) {
let confidences: Vec<crate::metrics::SkillConfidence> = stats
.iter()
.map(|s| {
let suc = u32::try_from(s.successes).unwrap_or(0);
let fail = u32::try_from(s.failures).unwrap_or(0);
crate::metrics::SkillConfidence {
name: s.skill_name.clone(),
posterior: zeph_skills::trust_score::posterior_mean(suc, fail),
total_uses: u32::try_from(s.total).unwrap_or(0),
}
})
.collect();
self.update_metrics(|m| m.skill_confidence = confidences);
}
}