mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat(engine): consolidate action execution, remove reflection, add learning missions
Three major changes to the v2 engine:
1. **Consolidated action execution** — `handle_execute_action` in Rust is now
the single source of truth for lease lookup, policy check, lease consumption,
action execution, event emission, and ActionResult message recording. The
Python orchestrator no longer duplicates event/message logic. This fixes the
empty call_id bug (OpenAI HTTP 400) and the missing tool_calls on assistant
messages (Codex "No tool call found" error).
2. **Removed reflection system** — Deleted the per-thread reflection pipeline
(pipeline.rs, executor.rs), ThreadState::Reflecting, ThreadType::Reflection,
enable_reflection config, and all 3 reflection event kinds. Learning is now
handled entirely by event-driven missions that fire selectively.
3. **Three learning missions** replace reflection:
- `self-improvement` — fires on trace issues (error diagnosis, prompt fixes)
- `playbook-extraction` — fires on successful 5+ step threads (reusable procedures)
- `conversation-insights` — fires every 5 threads per project (user preferences,
domain knowledge, workflow patterns)
Additional fixes:
- llm_query()/llm_query_batched() always include system message (Codex compat)
- handle_llm_complete adds assistant message with structured action_calls for
Tier 0 responses (prevents "No tool call found" errors)
- Gateway broadcasts without thread_id emit as Status events instead of being dropped
- Comprehensive tests for call_id propagation and trace analysis (17 new tests)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -67,9 +67,6 @@ src/
|
||||
├── memory/ # Memory document system
|
||||
│ ├── store.rs # MemoryStore — project-scoped doc CRUD
|
||||
│ └── retrieval.rs # RetrievalEngine — keyword-based context retrieval from project docs
|
||||
├── reflection/ # Post-thread reflection pipeline
|
||||
│ ├── pipeline.rs # reflect() (CodeAct) + reflect_simple() (direct LLM) + output parsing
|
||||
│ └── executor.rs # ReflectionExecutor — read-only tools for reflection threads
|
||||
└── reliability.rs # ReliabilityTracker — per-action success rate and latency via EMA
|
||||
```
|
||||
|
||||
@@ -78,12 +75,22 @@ src/
|
||||
```
|
||||
Created → Running → Waiting → Running (resume)
|
||||
→ Suspended → Running (resume)
|
||||
→ Completed → Reflecting → Done
|
||||
→ Completed → Done
|
||||
→ Failed
|
||||
```
|
||||
|
||||
Validated by `ThreadState::can_transition_to()`. Terminal states: `Done`, `Failed`.
|
||||
|
||||
## Learning Missions
|
||||
|
||||
Three event-driven missions fire automatically after thread completion:
|
||||
|
||||
1. **Error diagnosis** (`self-improvement`) — fires when a thread completes with trace issues. Diagnoses root cause and applies prompt overlays or orchestrator patches.
|
||||
2. **Playbook extraction** (`playbook-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable step-by-step procedures.
|
||||
3. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns.
|
||||
|
||||
Created by `MissionManager::ensure_learning_missions()` at project bootstrap.
|
||||
|
||||
## External Trait Boundaries
|
||||
|
||||
The engine defines three traits that the host crate implements:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# by the self-improvement Mission.
|
||||
#
|
||||
# Host functions (provided by Rust via Monty suspension):
|
||||
# __llm_complete__(messages, actions, config) -> response dict
|
||||
# __llm_complete__(messages, actions, config) -> response dict (args ignored; Rust builds context from thread)
|
||||
# __execute_code_step__(code, state) -> result dict
|
||||
# __execute_action__(name, params) -> result dict
|
||||
# __check_signals__() -> None | "stop" | {"inject": msg}
|
||||
@@ -243,21 +243,20 @@ def run_loop(context, goal, actions, state, config):
|
||||
})
|
||||
|
||||
elif resp_type == "actions":
|
||||
# Tier 0: structured tool calls
|
||||
# Tier 0: structured tool calls.
|
||||
# The assistant message with structured action_calls is added by
|
||||
# __llm_complete__ in Rust — do NOT add it here.
|
||||
nudge_count = 0
|
||||
calls = response.get("calls", [])
|
||||
__add_message__("assistant_actions", str(calls))
|
||||
|
||||
for call in calls:
|
||||
name = call.get("name", "")
|
||||
params = call.get("params", {})
|
||||
call_id = call.get("call_id", "")
|
||||
|
||||
r = __execute_action__(name, params)
|
||||
|
||||
__emit_event__("action_executed" if not r.get("is_error") else "action_failed",
|
||||
action_name=name, call_id=call_id)
|
||||
__add_message__("action_result", str(r.get("output", {})))
|
||||
# __execute_action__ handles event emission, message addition,
|
||||
# and lease consumption in Rust — no duplicate logic needed here.
|
||||
r = __execute_action__(name, params, call_id=call_id)
|
||||
|
||||
if r.get("need_approval"):
|
||||
__save_checkpoint__(state, {
|
||||
|
||||
@@ -23,18 +23,11 @@ impl LeasePlanner {
|
||||
}
|
||||
|
||||
/// Build the capability grants for a new thread.
|
||||
///
|
||||
/// Reflection threads are handled by the reflection pipeline's dedicated
|
||||
/// executor, so the default planner grants no host capabilities to them.
|
||||
pub fn plan_for_thread(
|
||||
&self,
|
||||
thread_type: ThreadType,
|
||||
_thread_type: ThreadType,
|
||||
capabilities: &CapabilityRegistry,
|
||||
) -> Vec<CapabilityGrantPlan> {
|
||||
if thread_type == ThreadType::Reflection {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
capabilities
|
||||
.list()
|
||||
.into_iter()
|
||||
@@ -89,10 +82,4 @@ mod tests {
|
||||
assert_eq!(plans[0].granted_actions, vec!["read_file"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_threads_do_not_get_default_capabilities() {
|
||||
let planner = LeasePlanner::new();
|
||||
let plans = planner.plan_for_thread(ThreadType::Reflection, ®istry());
|
||||
assert!(plans.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,8 +151,8 @@ impl PolicyEngine {
|
||||
}
|
||||
// User and System provenance are trusted
|
||||
Provenance::User | Provenance::System => {}
|
||||
// Reflection and MemoryRetrieval are internal, treat as trusted
|
||||
Provenance::Reflection { .. } | Provenance::MemoryRetrieval { .. } => {}
|
||||
// MemoryRetrieval is internal, treat as trusted
|
||||
Provenance::MemoryRetrieval { .. } => {}
|
||||
}
|
||||
|
||||
decision
|
||||
|
||||
@@ -1112,4 +1112,225 @@ mod tests {
|
||||
let text = "A very long explanation...\n\n🔚 Final Thought\n\nFINAL(\"the conclusion\")";
|
||||
assert_eq!(extract_final_from_text(text).unwrap(), "the conclusion");
|
||||
}
|
||||
|
||||
// ── call_id propagation through orchestrator pipeline ────
|
||||
//
|
||||
// These tests verify the end-to-end flow: LLM returns ActionCalls with
|
||||
// call_ids → orchestrator executes them → ActionResult messages on the
|
||||
// thread have correct call_ids (not empty). This catches the class of
|
||||
// bugs that caused OpenAI/Codex HTTP 400 rejections.
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_result_messages_have_correct_call_id() {
|
||||
// LLM returns a tool call, then a text response
|
||||
let (mut exec, _tx) = make_loop(
|
||||
vec![
|
||||
action_response("test_tool", "call_xK9mZq123"),
|
||||
text_response("Done!"),
|
||||
],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(), // EffectExecutor returns empty
|
||||
action_name: "test_tool".into(),
|
||||
output: serde_json::json!({"data": "result"}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(5),
|
||||
})],
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
exec.run().await.unwrap();
|
||||
|
||||
// Find the ActionResult message on the thread
|
||||
let action_results: Vec<_> = exec
|
||||
.thread
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.role == crate::types::message::MessageRole::ActionResult)
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!action_results.is_empty(),
|
||||
"thread should have at least one ActionResult message"
|
||||
);
|
||||
|
||||
for msg in &action_results {
|
||||
let call_id = msg.action_call_id.as_deref().unwrap_or("");
|
||||
assert!(
|
||||
!call_id.is_empty(),
|
||||
"ActionResult message must have non-empty call_id, got empty for tool '{}'",
|
||||
msg.action_name.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that the ActionExecuted event carries the call_id from the LLM.
|
||||
#[tokio::test]
|
||||
async fn action_executed_events_carry_call_id() {
|
||||
let (mut exec, _tx) = make_loop(
|
||||
vec![
|
||||
action_response("test_tool", "call_evt_id_42"),
|
||||
text_response("ok"),
|
||||
],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: "test_tool".into(),
|
||||
output: serde_json::json!({}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
})],
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
exec.run().await.unwrap();
|
||||
|
||||
let exec_events: Vec<_> = exec
|
||||
.thread
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EventKind::ActionExecuted { call_id, .. } => Some(call_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(!exec_events.is_empty(), "should have ActionExecuted events");
|
||||
for call_id in &exec_events {
|
||||
assert!(!call_id.is_empty(), "ActionExecuted event must have non-empty call_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// When a tool call fails (no lease), the ActionResult message and
|
||||
/// ActionFailed event must still carry the original call_id.
|
||||
#[tokio::test]
|
||||
async fn failed_action_preserves_call_id_in_message_and_event() {
|
||||
let project_id = ProjectId::new();
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
project_id,
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let tid = thread.id;
|
||||
|
||||
// Create a tool that requires a separate capability
|
||||
let missing_action = ActionDef {
|
||||
name: "restricted_tool".into(),
|
||||
description: "A tool with no lease".into(),
|
||||
parameters_schema: serde_json::json!({"type": "object"}),
|
||||
effects: vec![EffectType::WriteExternal],
|
||||
requires_approval: false,
|
||||
};
|
||||
|
||||
let llm = Arc::new(MockLlm::new(vec![
|
||||
// LLM calls a tool the thread has no lease for
|
||||
LlmOutput {
|
||||
response: LlmResponse::ActionCalls {
|
||||
calls: vec![crate::types::step::ActionCall {
|
||||
id: "call_nolease_xyz".into(),
|
||||
action_name: "restricted_tool".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
}],
|
||||
content: None,
|
||||
},
|
||||
usage: TokenUsage::default(),
|
||||
},
|
||||
text_response("I couldn't access that tool"),
|
||||
]));
|
||||
let effects = Arc::new(MockEffects::new(vec![missing_action], vec![]));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
|
||||
// Grant a lease that does NOT cover "restricted_tool"
|
||||
leases
|
||||
.grant(tid, "basic_cap", vec![], None, None)
|
||||
.await;
|
||||
|
||||
let (_tx, rx) = crate::runtime::messaging::signal_channel(16);
|
||||
let mut exec = ExecutionLoop::new(
|
||||
thread,
|
||||
llm,
|
||||
effects,
|
||||
leases,
|
||||
policy,
|
||||
rx,
|
||||
"test-user".into(),
|
||||
);
|
||||
|
||||
exec.run().await.unwrap();
|
||||
|
||||
// Check ActionResult messages
|
||||
let action_results: Vec<_> = exec
|
||||
.thread
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.role == crate::types::message::MessageRole::ActionResult)
|
||||
.collect();
|
||||
|
||||
for msg in &action_results {
|
||||
let call_id = msg.action_call_id.as_deref().unwrap_or("");
|
||||
assert!(
|
||||
!call_id.is_empty(),
|
||||
"even failed ActionResult must have call_id"
|
||||
);
|
||||
}
|
||||
|
||||
// Check ActionFailed events
|
||||
let fail_events: Vec<_> = exec
|
||||
.thread
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EventKind::ActionFailed {
|
||||
call_id,
|
||||
action_name,
|
||||
..
|
||||
} => Some((call_id.clone(), action_name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (call_id, _name) in &fail_events {
|
||||
assert!(
|
||||
!call_id.is_empty(),
|
||||
"ActionFailed event must have call_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the trace analyzer does NOT flag any issues on a clean
|
||||
/// action execution (no empty call_ids).
|
||||
#[tokio::test]
|
||||
async fn trace_analysis_clean_after_successful_tool_use() {
|
||||
let (mut exec, _tx) = make_loop(
|
||||
vec![
|
||||
action_response("test_tool", "call_clean_id"),
|
||||
text_response("All done"),
|
||||
],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: "test_tool".into(),
|
||||
output: serde_json::json!({"status": "ok"}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(3),
|
||||
})],
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
exec.run().await.unwrap();
|
||||
|
||||
let trace = crate::executor::trace::build_trace(&exec.thread);
|
||||
let empty_id_issues: Vec<_> = trace
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| i.category == "empty_call_id")
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
empty_id_issues.is_empty(),
|
||||
"clean execution should have no empty_call_id issues, got: {empty_id_issues:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,9 +338,12 @@ pub async fn execute_orchestrator(
|
||||
.await
|
||||
}
|
||||
|
||||
// __execute_action__(name, params)
|
||||
// __execute_action__(name, params, call_id=...)
|
||||
"__execute_action__" => {
|
||||
handle_execute_action(args, kwargs, thread, effects, leases, policy).await
|
||||
handle_execute_action(
|
||||
args, kwargs, thread, effects, leases, policy, event_tx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// __check_signals__()
|
||||
@@ -441,10 +444,14 @@ pub async fn execute_orchestrator(
|
||||
///
|
||||
/// Calls the LLM and returns the response as a dict:
|
||||
/// `{type: "text"|"code"|"actions", content/code/calls: ..., usage: {...}}`
|
||||
///
|
||||
/// For `ActionCalls` responses, the assistant message with structured action_calls
|
||||
/// is added directly to the thread (not by Python) so the LLM backend can convert
|
||||
/// them to the provider-specific tool_calls format on the next call.
|
||||
async fn handle_llm_complete(
|
||||
_args: &[MontyObject],
|
||||
_kwargs: &[(MontyObject, MontyObject)],
|
||||
thread: &Thread,
|
||||
thread: &mut Thread,
|
||||
llm: &Arc<dyn LlmBackend>,
|
||||
effects: &Arc<dyn EffectExecutor>,
|
||||
leases: &Arc<LeaseManager>,
|
||||
@@ -485,7 +492,16 @@ async fn handle_llm_complete(
|
||||
LlmResponse::Code { code, .. } => {
|
||||
serde_json::json!({"type": "code", "code": code, "usage": usage})
|
||||
}
|
||||
LlmResponse::ActionCalls { calls, .. } => {
|
||||
LlmResponse::ActionCalls { calls, content } => {
|
||||
// Add the assistant message with structured action_calls so the
|
||||
// LLM backend sees proper tool_calls on the next round-trip.
|
||||
// Python must NOT call __add_message__("assistant_actions", ...) —
|
||||
// the message is already on the thread.
|
||||
thread.add_message(ThreadMessage::assistant_with_actions(
|
||||
content,
|
||||
calls.clone(),
|
||||
));
|
||||
|
||||
let calls_json: Vec<serde_json::Value> = calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -602,14 +618,25 @@ async fn handle_execute_code_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `__execute_action__(name, params)`.
|
||||
/// Handle `__execute_action__(name, params, call_id=...)`.
|
||||
///
|
||||
/// Single source of truth for action execution. Performs:
|
||||
/// 1. Lease lookup
|
||||
/// 2. Policy check
|
||||
/// 3. Lease consumption
|
||||
/// 4. Action execution via EffectExecutor
|
||||
/// 5. Event emission (ActionExecuted/ActionFailed)
|
||||
/// 6. Message addition (ActionResult with correct call_id)
|
||||
///
|
||||
/// Python only needs to check the returned `need_approval` flag.
|
||||
async fn handle_execute_action(
|
||||
args: &[MontyObject],
|
||||
kwargs: &[(MontyObject, MontyObject)],
|
||||
thread: &Thread,
|
||||
thread: &mut Thread,
|
||||
effects: &Arc<dyn EffectExecutor>,
|
||||
leases: &Arc<LeaseManager>,
|
||||
policy: &Arc<PolicyEngine>,
|
||||
event_tx: Option<&tokio::sync::broadcast::Sender<ThreadEvent>>,
|
||||
) -> ExtFunctionResult {
|
||||
let name = match extract_string_arg(args, kwargs, "name", 0) {
|
||||
Some(n) => n,
|
||||
@@ -626,6 +653,8 @@ async fn handle_execute_action(
|
||||
.map(monty_to_json)
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let call_id = extract_string_kwarg(kwargs, "call_id").unwrap_or_default();
|
||||
|
||||
let exec_ctx = ThreadExecutionContext {
|
||||
thread_id: thread.id,
|
||||
thread_type: thread.thread_type,
|
||||
@@ -634,19 +663,50 @@ async fn handle_execute_action(
|
||||
step_id: StepId::new(),
|
||||
};
|
||||
|
||||
// Find lease for this action
|
||||
// Helper: emit event and add ActionResult message to thread
|
||||
let emit_and_record = |thread: &mut Thread,
|
||||
event_tx: Option<&tokio::sync::broadcast::Sender<ThreadEvent>>,
|
||||
event_kind: EventKind,
|
||||
call_id: &str,
|
||||
action_name: &str,
|
||||
output: &serde_json::Value| {
|
||||
let event = ThreadEvent::new(thread.id, event_kind);
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx.send(event.clone());
|
||||
}
|
||||
thread.events.push(event);
|
||||
thread.updated_at = chrono::Utc::now();
|
||||
thread.add_message(ThreadMessage::action_result(call_id, action_name, output.to_string()));
|
||||
};
|
||||
|
||||
// 1. Find lease for this action
|
||||
let lease = match leases.find_lease_for_action(thread.id, &name).await {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let error = format!("No lease for action '{name}'");
|
||||
let output = serde_json::json!({"error": &error});
|
||||
emit_and_record(
|
||||
thread,
|
||||
event_tx,
|
||||
EventKind::ActionFailed {
|
||||
step_id: exec_ctx.step_id,
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
&output,
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"output": {"error": format!("No lease for action '{name}'")},
|
||||
"output": output,
|
||||
"is_error": true,
|
||||
});
|
||||
return ExtFunctionResult::Return(json_to_monty(&result));
|
||||
}
|
||||
};
|
||||
|
||||
// Check policy
|
||||
// 2. Check policy
|
||||
let action_def = effects
|
||||
.available_actions(std::slice::from_ref(&lease))
|
||||
.await
|
||||
@@ -656,8 +716,22 @@ async fn handle_execute_action(
|
||||
if let Some(ref ad) = action_def {
|
||||
match policy.evaluate(ad, &lease, &[]) {
|
||||
crate::capability::policy::PolicyDecision::Deny { reason } => {
|
||||
let output = serde_json::json!({"error": format!("Denied: {reason}")});
|
||||
emit_and_record(
|
||||
thread,
|
||||
event_tx,
|
||||
EventKind::ActionFailed {
|
||||
step_id: exec_ctx.step_id,
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error: reason,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
&output,
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"output": {"error": format!("Denied: {reason}")},
|
||||
"output": output,
|
||||
"is_error": true,
|
||||
});
|
||||
return ExtFunctionResult::Return(json_to_monty(&result));
|
||||
@@ -673,12 +747,30 @@ async fn handle_execute_action(
|
||||
}
|
||||
}
|
||||
|
||||
// Execute
|
||||
// 3. Consume a lease use
|
||||
if let Err(e) = leases.consume_use(lease.id).await {
|
||||
debug!(error = %e, "lease consumption failed (non-fatal)");
|
||||
}
|
||||
|
||||
// 4. Execute
|
||||
match effects
|
||||
.execute_action(&name, params, &lease, &exec_ctx)
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
emit_and_record(
|
||||
thread,
|
||||
event_tx,
|
||||
EventKind::ActionExecuted {
|
||||
step_id: exec_ctx.step_id,
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
duration_ms: r.duration.as_millis() as u64,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
&r.output,
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"action_name": r.action_name,
|
||||
"output": r.output,
|
||||
@@ -688,8 +780,22 @@ async fn handle_execute_action(
|
||||
ExtFunctionResult::Return(json_to_monty(&result))
|
||||
}
|
||||
Err(e) => {
|
||||
let output = serde_json::json!({"error": e.to_string()});
|
||||
emit_and_record(
|
||||
thread,
|
||||
event_tx,
|
||||
EventKind::ActionFailed {
|
||||
step_id: exec_ctx.step_id,
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error: e.to_string(),
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
&output,
|
||||
);
|
||||
let result = serde_json::json!({
|
||||
"output": {"error": e.to_string()},
|
||||
"output": output,
|
||||
"is_error": true,
|
||||
});
|
||||
ExtFunctionResult::Return(json_to_monty(&result))
|
||||
@@ -784,6 +890,10 @@ fn handle_emit_event(
|
||||
}
|
||||
|
||||
/// Handle `__add_message__(role, content)`.
|
||||
///
|
||||
/// ActionResult messages are NOT added here — they are handled by
|
||||
/// `__execute_action__` which is the single source of truth for
|
||||
/// action execution, event emission, and message recording.
|
||||
fn handle_add_message(
|
||||
args: &[MontyObject],
|
||||
_kwargs: &[(MontyObject, MontyObject)],
|
||||
@@ -794,7 +904,7 @@ fn handle_add_message(
|
||||
|
||||
match role.as_str() {
|
||||
"user" => thread.add_message(ThreadMessage::user(&content)),
|
||||
"assistant" | "assistant_actions" => thread.add_message(ThreadMessage::assistant(&content)),
|
||||
"assistant" => thread.add_message(ThreadMessage::assistant(&content)),
|
||||
"system" => thread.add_message(ThreadMessage::system(&content)),
|
||||
"system_append" => {
|
||||
// Append to existing system message (for doc injection)
|
||||
@@ -807,15 +917,11 @@ fn handle_add_message(
|
||||
msg.content.push_str(&content);
|
||||
}
|
||||
}
|
||||
"action_result" => {
|
||||
thread.add_message(ThreadMessage::action_result("", "", &content));
|
||||
}
|
||||
_ => {
|
||||
thread.add_message(ThreadMessage::user(&content));
|
||||
}
|
||||
}
|
||||
|
||||
thread.step_count += 0; // Message addition tracked by thread itself
|
||||
ExtFunctionResult::Return(MontyObject::None)
|
||||
}
|
||||
|
||||
|
||||
@@ -770,7 +770,6 @@ async fn handle_rlm_query(
|
||||
// Build child thread with inherited budget
|
||||
let child_config = crate::types::thread::ThreadConfig {
|
||||
max_iterations: parent_thread.config.max_iterations.min(20), // cap child iterations
|
||||
enable_reflection: false,
|
||||
enable_tool_intent_nudge: false,
|
||||
max_tokens_total: parent_thread
|
||||
.config
|
||||
|
||||
@@ -166,3 +166,322 @@ pub async fn execute_action_calls(
|
||||
need_approval: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::effect::ThreadExecutionContext;
|
||||
use crate::types::capability::{ActionDef, CapabilityLease, EffectType};
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::StepId;
|
||||
use crate::types::thread::{Thread, ThreadConfig, ThreadType};
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
struct MockEffects {
|
||||
results: Mutex<Vec<Result<ActionResult, EngineError>>>,
|
||||
actions: Vec<ActionDef>,
|
||||
}
|
||||
|
||||
impl MockEffects {
|
||||
fn new(actions: Vec<ActionDef>, results: Vec<Result<ActionResult, EngineError>>) -> Self {
|
||||
Self {
|
||||
results: Mutex::new(results),
|
||||
actions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EffectExecutor for MockEffects {
|
||||
async fn execute_action(
|
||||
&self,
|
||||
_name: &str,
|
||||
_params: serde_json::Value,
|
||||
_lease: &CapabilityLease,
|
||||
_ctx: &ThreadExecutionContext,
|
||||
) -> Result<ActionResult, EngineError> {
|
||||
let mut results = self.results.lock().unwrap();
|
||||
if results.is_empty() {
|
||||
Ok(ActionResult {
|
||||
call_id: String::new(), // EffectExecutor doesn't set call_id
|
||||
action_name: String::new(),
|
||||
output: serde_json::json!({"result": "ok"}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
})
|
||||
} else {
|
||||
results.remove(0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn available_actions(
|
||||
&self,
|
||||
_leases: &[CapabilityLease],
|
||||
) -> Result<Vec<ActionDef>, EngineError> {
|
||||
Ok(self.actions.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_action(name: &str) -> ActionDef {
|
||||
ActionDef {
|
||||
name: name.into(),
|
||||
description: "Test tool".into(),
|
||||
parameters_schema: serde_json::json!({"type": "object"}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_exec_context(thread: &Thread) -> ThreadExecutionContext {
|
||||
ThreadExecutionContext {
|
||||
thread_id: thread.id,
|
||||
thread_type: thread.thread_type,
|
||||
project_id: thread.project_id,
|
||||
user_id: "test".into(),
|
||||
step_id: StepId::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── call_id propagation tests ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_on_successful_execution() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("web_search")],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(), // EffectExecutor returns empty
|
||||
action_name: "web_search".into(),
|
||||
output: serde_json::json!({"results": []}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(42),
|
||||
})],
|
||||
));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
leases.grant(thread.id, "search", vec![], None, None).await;
|
||||
|
||||
let calls = vec![ActionCall {
|
||||
id: "call_r2o5mqBgdNUlH8KzskncUGaX".into(),
|
||||
action_name: "web_search".into(),
|
||||
parameters: serde_json::json!({"query": "test"}),
|
||||
}];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// call_id must be stamped from ActionCall, not the empty EffectExecutor return
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].call_id, "call_r2o5mqBgdNUlH8KzskncUGaX");
|
||||
assert_eq!(result.results[0].action_name, "web_search");
|
||||
assert!(!result.results[0].is_error);
|
||||
|
||||
// Event should carry the same call_id
|
||||
let exec_event = result.events.iter().find(|e| matches!(e, EventKind::ActionExecuted { .. }));
|
||||
assert!(exec_event.is_some());
|
||||
if let Some(EventKind::ActionExecuted { call_id, action_name, .. }) = exec_event {
|
||||
assert_eq!(call_id, "call_r2o5mqBgdNUlH8KzskncUGaX");
|
||||
assert_eq!(action_name, "web_search");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_on_execution_error() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("shell")],
|
||||
vec![Err(EngineError::Effect {
|
||||
reason: "permission denied".into(),
|
||||
})],
|
||||
));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
leases.grant(thread.id, "exec", vec![], None, None).await;
|
||||
|
||||
let calls = vec![ActionCall {
|
||||
id: "call_abc123def".into(),
|
||||
action_name: "shell".into(),
|
||||
parameters: serde_json::json!({"cmd": "ls"}),
|
||||
}];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].call_id, "call_abc123def");
|
||||
assert!(result.results[0].is_error);
|
||||
|
||||
let fail_event = result.events.iter().find(|e| matches!(e, EventKind::ActionFailed { .. }));
|
||||
assert!(fail_event.is_some());
|
||||
if let Some(EventKind::ActionFailed { call_id, .. }) = fail_event {
|
||||
assert_eq!(call_id, "call_abc123def");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_when_no_lease() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(vec![], vec![]));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
// No lease granted — action should fail with correct call_id
|
||||
let calls = vec![ActionCall {
|
||||
id: "call_no_lease_123".into(),
|
||||
action_name: "web_search".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].call_id, "call_no_lease_123");
|
||||
assert!(result.results[0].is_error);
|
||||
|
||||
if let Some(EventKind::ActionFailed { call_id, error, .. }) = result.events.first() {
|
||||
assert_eq!(call_id, "call_no_lease_123");
|
||||
assert!(error.contains("no lease"));
|
||||
} else {
|
||||
panic!("expected ActionFailed event");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_calls_each_get_correct_call_id() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("tool_a"), test_action("tool_b")],
|
||||
vec![
|
||||
Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: "tool_a".into(),
|
||||
output: serde_json::json!("a_result"),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
}),
|
||||
Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: "tool_b".into(),
|
||||
output: serde_json::json!("b_result"),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(2),
|
||||
}),
|
||||
],
|
||||
));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
leases.grant(thread.id, "cap", vec![], None, None).await;
|
||||
|
||||
let calls = vec![
|
||||
ActionCall {
|
||||
id: "id_aaaa".into(),
|
||||
action_name: "tool_a".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
},
|
||||
ActionCall {
|
||||
id: "id_bbbb".into(),
|
||||
action_name: "tool_b".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
},
|
||||
];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.results.len(), 2);
|
||||
assert_eq!(result.results[0].call_id, "id_aaaa");
|
||||
assert_eq!(result.results[1].call_id, "id_bbbb");
|
||||
}
|
||||
|
||||
/// Provider-specific: OpenAI rejects empty string call_id. Verify no result
|
||||
/// ever has an empty call_id when the ActionCall provided one.
|
||||
#[tokio::test]
|
||||
async fn openai_empty_call_id_never_produced() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("echo")],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(), // EffectExecutor always returns empty
|
||||
action_name: String::new(),
|
||||
output: serde_json::json!("hello"),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
})],
|
||||
));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
leases.grant(thread.id, "cap", vec![], None, None).await;
|
||||
|
||||
let calls = vec![ActionCall {
|
||||
id: "aB3xK9mZq".into(), // Mistral-compatible 9-char ID
|
||||
action_name: "echo".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Must NOT be empty — must be stamped from the ActionCall
|
||||
assert!(!result.results[0].call_id.is_empty());
|
||||
assert_eq!(result.results[0].call_id, "aB3xK9mZq");
|
||||
}
|
||||
|
||||
/// Mistral requires call_id matching [a-zA-Z0-9]{9}.
|
||||
/// Verify the ID passes through unmodified (normalization is LLM-layer concern,
|
||||
/// but engine must never lose it).
|
||||
#[tokio::test]
|
||||
async fn mistral_format_call_id_preserved() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("web_search")],
|
||||
vec![Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: "web_search".into(),
|
||||
output: serde_json::json!({}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
})],
|
||||
));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let ctx = make_exec_context(&thread);
|
||||
|
||||
leases.grant(thread.id, "cap", vec![], None, None).await;
|
||||
|
||||
// Mistral format: exactly 9 alphanumeric chars
|
||||
let mistral_id = "xK3mR9bZq";
|
||||
let calls = vec![ActionCall {
|
||||
id: mistral_id.into(),
|
||||
action_name: "web_search".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
let result = execute_action_calls(&calls, &thread, &effects, &leases, &policy, &ctx, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.results[0].call_id, mistral_id);
|
||||
|
||||
// Event also preserves the exact format
|
||||
if let Some(EventKind::ActionExecuted { call_id, .. }) = result.events.first() {
|
||||
assert_eq!(call_id, mistral_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,20 +33,12 @@ pub struct ExecutionTrace {
|
||||
pub messages: Vec<MessageRecord>,
|
||||
pub events: Vec<ThreadEvent>,
|
||||
pub issues: Vec<TraceIssue>,
|
||||
pub reflection: Option<ReflectionTrace>,
|
||||
pub timestamp: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Reflection results captured in the trace.
|
||||
/// A single doc record, for the trace.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReflectionTrace {
|
||||
pub docs: Vec<ReflectionDocRecord>,
|
||||
pub tokens_used: u64,
|
||||
}
|
||||
|
||||
/// A single doc produced by reflection, for the trace.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReflectionDocRecord {
|
||||
pub struct DocRecord {
|
||||
pub doc_type: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
@@ -72,7 +64,7 @@ pub struct TraceIssue {
|
||||
pub step: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
pub enum IssueSeverity {
|
||||
Error,
|
||||
Warning,
|
||||
@@ -112,7 +104,6 @@ pub fn build_trace(thread: &Thread) -> ExecutionTrace {
|
||||
messages,
|
||||
events: thread.events.clone(),
|
||||
issues,
|
||||
reflection: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -140,22 +131,6 @@ pub fn write_trace(trace: &ExecutionTrace) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach reflection results to a trace.
|
||||
pub fn attach_reflection(trace: &mut ExecutionTrace, result: &crate::reflection::ReflectionResult) {
|
||||
trace.reflection = Some(ReflectionTrace {
|
||||
docs: result
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| ReflectionDocRecord {
|
||||
doc_type: format!("{:?}", d.doc_type),
|
||||
title: d.title.clone(),
|
||||
content: d.content.clone(),
|
||||
})
|
||||
.collect(),
|
||||
tokens_used: result.tokens_used.total(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Print a summary of the trace to the log.
|
||||
pub fn log_trace_summary(trace: &ExecutionTrace) {
|
||||
debug!(
|
||||
@@ -192,28 +167,6 @@ pub fn log_trace_summary(trace: &ExecutionTrace) {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref refl) = trace.reflection {
|
||||
debug!(
|
||||
thread_id = %trace.thread_id,
|
||||
docs = refl.docs.len(),
|
||||
tokens = refl.tokens_used,
|
||||
"=== Reflection ==="
|
||||
);
|
||||
for doc in &refl.docs {
|
||||
let preview: String = doc.content.chars().take(200).collect();
|
||||
let truncated = if doc.content.chars().count() > 200 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
debug!(
|
||||
doc_type = %doc.doc_type,
|
||||
title = %doc.title,
|
||||
" {preview}{truncated}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retrospective analysis ──────────────────────────────────
|
||||
@@ -429,3 +382,177 @@ fn truncate(s: &str, max_chars: usize) -> String {
|
||||
chars
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::event::EventKind;
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::StepId;
|
||||
use crate::types::thread::{ThreadConfig, ThreadType};
|
||||
|
||||
fn make_thread() -> Thread {
|
||||
Thread::new(
|
||||
"test goal",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── empty_call_id detection (OpenAI / Codex rejection) ───
|
||||
|
||||
/// OpenAI and Codex reject ActionResult messages with empty call_id.
|
||||
/// The trace analyzer must flag these as errors.
|
||||
#[test]
|
||||
fn detects_empty_call_id_on_action_result() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("calling tool"));
|
||||
// Simulate the bug: empty call_id
|
||||
thread.add_message(ThreadMessage::action_result("", "web_search", "result"));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
let empty_id_issues: Vec<_> = issues
|
||||
.iter()
|
||||
.filter(|i| i.category == "empty_call_id")
|
||||
.collect();
|
||||
|
||||
assert_eq!(empty_id_issues.len(), 1);
|
||||
assert_eq!(empty_id_issues[0].severity, IssueSeverity::Error);
|
||||
assert!(empty_id_issues[0].description.contains("web_search"));
|
||||
}
|
||||
|
||||
/// ActionResult with None call_id should also be flagged.
|
||||
#[test]
|
||||
fn detects_none_call_id_on_action_result() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("calling tool"));
|
||||
// Manually construct a message with None call_id
|
||||
thread.add_message(ThreadMessage {
|
||||
role: crate::types::message::MessageRole::ActionResult,
|
||||
content: "result".into(),
|
||||
provenance: crate::types::provenance::Provenance::ToolOutput {
|
||||
action_name: "shell".into(),
|
||||
},
|
||||
action_call_id: None,
|
||||
action_name: Some("shell".into()),
|
||||
action_calls: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
assert!(issues.iter().any(|i| i.category == "empty_call_id"));
|
||||
}
|
||||
|
||||
/// No false positive: valid call_id should not be flagged.
|
||||
#[test]
|
||||
fn no_false_positive_for_valid_call_id() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("calling tool"));
|
||||
thread.add_message(ThreadMessage::action_result(
|
||||
"call_abc123",
|
||||
"web_search",
|
||||
"result",
|
||||
));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
assert!(
|
||||
!issues.iter().any(|i| i.category == "empty_call_id"),
|
||||
"valid call_id should not be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
// ── tool_error detection ─────────────────────────────────
|
||||
|
||||
/// ActionFailed events should produce tool_error warnings.
|
||||
#[test]
|
||||
fn detects_tool_failures_in_events() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("ok"));
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionFailed {
|
||||
step_id: StepId::new(),
|
||||
action_name: "web_search".into(),
|
||||
call_id: "call_123".into(),
|
||||
error: "No lease for action 'web_search'".into(),
|
||||
},
|
||||
));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
let tool_errors: Vec<_> = issues
|
||||
.iter()
|
||||
.filter(|i| i.category == "tool_error")
|
||||
.collect();
|
||||
assert_eq!(tool_errors.len(), 1);
|
||||
assert!(tool_errors[0].description.contains("web_search"));
|
||||
}
|
||||
|
||||
// ── thread_failure detection ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn detects_failed_thread_state() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("trying"));
|
||||
thread.state = ThreadState::Failed;
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
assert!(issues.iter().any(|i| i.category == "thread_failure"));
|
||||
}
|
||||
|
||||
// ── LLM error detection from StateChanged events ─────────
|
||||
|
||||
/// Reproduces the exact pattern from the trace: OpenAI rejects empty call_id.
|
||||
#[test]
|
||||
fn detects_llm_error_from_state_changed() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("ok"));
|
||||
thread.state = ThreadState::Failed;
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::StateChanged {
|
||||
from: ThreadState::Running,
|
||||
to: ThreadState::Failed,
|
||||
reason: Some(
|
||||
"LLM error: Provider openai_codex request failed: HTTP 400 Bad Request: \
|
||||
Invalid 'input[5].call_id': empty string"
|
||||
.into(),
|
||||
),
|
||||
},
|
||||
));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
assert!(
|
||||
issues.iter().any(|i| i.category == "llm_error"),
|
||||
"should detect LLM provider error in StateChanged reason"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Multiple empty call_ids ──────────────────────────────
|
||||
|
||||
/// Anthropic sends consecutive tool results merged into one User message.
|
||||
/// If multiple ActionResults have empty call_ids, each must be flagged.
|
||||
#[test]
|
||||
fn flags_each_empty_call_id_separately() {
|
||||
let mut thread = make_thread();
|
||||
thread.add_message(ThreadMessage::system("sys"));
|
||||
thread.add_message(ThreadMessage::assistant("parallel calls"));
|
||||
thread.add_message(ThreadMessage::action_result("", "tool_a", "result_a"));
|
||||
thread.add_message(ThreadMessage::action_result("", "tool_b", "result_b"));
|
||||
thread.add_message(ThreadMessage::action_result("call_ok", "tool_c", "result_c"));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
let empty_issues: Vec<_> = issues
|
||||
.iter()
|
||||
.filter(|i| i.category == "empty_call_id")
|
||||
.collect();
|
||||
assert_eq!(empty_issues.len(), 2, "should flag exactly the 2 empty call_ids");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
pub mod capability;
|
||||
pub mod executor;
|
||||
pub mod memory;
|
||||
pub mod reflection;
|
||||
pub mod reliability;
|
||||
pub mod runtime;
|
||||
pub mod traits;
|
||||
@@ -75,10 +74,6 @@ pub use executor::ExecutionLoop;
|
||||
pub use memory::MemoryStore;
|
||||
pub use memory::RetrievalEngine;
|
||||
|
||||
// ── Re-exports: reflection ────────────────────────────────────
|
||||
|
||||
pub use reflection::ReflectionResult;
|
||||
|
||||
// ── Re-exports: reliability ──────────────────────────────────
|
||||
|
||||
pub use reliability::ReliabilityTracker;
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
//! Effect executor for reflection threads.
|
||||
//!
|
||||
//! Provides read-only tools that let the reflection CodeAct thread
|
||||
//! introspect the completed thread, query existing knowledge, and
|
||||
//! verify tool names against the capability registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::capability::registry::CapabilityRegistry;
|
||||
use crate::memory::RetrievalEngine;
|
||||
use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
|
||||
use crate::traits::store::Store;
|
||||
use crate::types::capability::{ActionDef, CapabilityLease, EffectType};
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::ActionResult;
|
||||
|
||||
/// EffectExecutor that provides reflection-specific read-only tools.
|
||||
pub struct ReflectionExecutor {
|
||||
store: Arc<dyn Store>,
|
||||
capabilities: Arc<CapabilityRegistry>,
|
||||
transcript: String,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
impl ReflectionExecutor {
|
||||
pub fn new(
|
||||
store: Arc<dyn Store>,
|
||||
capabilities: Arc<CapabilityRegistry>,
|
||||
transcript: String,
|
||||
project_id: ProjectId,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
capabilities,
|
||||
transcript,
|
||||
project_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_defs() -> Vec<ActionDef> {
|
||||
vec![
|
||||
ActionDef {
|
||||
name: "get_transcript".into(),
|
||||
description: "Get the full execution transcript of the completed thread, \
|
||||
including messages, tool calls, errors, and outcomes."
|
||||
.into(),
|
||||
parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
ActionDef {
|
||||
name: "query_memory".into(),
|
||||
description: "Search existing memory docs in this project for prior knowledge. \
|
||||
Use to check if a lesson or issue has already been recorded."
|
||||
.into(),
|
||||
parameters_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"max_docs": {"type": "integer", "description": "Max results (default 5)"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
ActionDef {
|
||||
name: "check_tool_exists".into(),
|
||||
description: "Check if a tool/action exists in the capability registry. \
|
||||
Returns whether it exists and lists similar tool names if not found."
|
||||
.into(),
|
||||
parameters_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Tool name to check"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
ActionDef {
|
||||
name: "list_tools".into(),
|
||||
description: "List all available tools/actions in the capability registry.".into(),
|
||||
parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EffectExecutor for ReflectionExecutor {
|
||||
async fn execute_action(
|
||||
&self,
|
||||
action_name: &str,
|
||||
parameters: serde_json::Value,
|
||||
_lease: &CapabilityLease,
|
||||
_context: &ThreadExecutionContext,
|
||||
) -> Result<ActionResult, EngineError> {
|
||||
let start = std::time::Instant::now();
|
||||
let output = match action_name {
|
||||
"get_transcript" => serde_json::json!({ "transcript": self.transcript }),
|
||||
|
||||
"query_memory" => {
|
||||
let query = parameters["query"].as_str().unwrap_or("");
|
||||
let max_docs = parameters["max_docs"].as_u64().unwrap_or(5) as usize;
|
||||
let retrieval = RetrievalEngine::new(Arc::clone(&self.store));
|
||||
let docs = retrieval
|
||||
.retrieve_context(self.project_id, query, max_docs)
|
||||
.await?;
|
||||
let results: Vec<serde_json::Value> = docs
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"type": format!("{:?}", d.doc_type),
|
||||
"title": &d.title,
|
||||
"content": &d.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "docs": results, "count": results.len() })
|
||||
}
|
||||
|
||||
"check_tool_exists" => {
|
||||
let name = parameters["name"].as_str().unwrap_or("");
|
||||
let exists = self.capabilities.find_action(name).is_some();
|
||||
let similar: Vec<String> = if exists {
|
||||
vec![]
|
||||
} else {
|
||||
// Find tools with similar names (substring or edit-distance-like match)
|
||||
let name_lower = name.to_lowercase();
|
||||
// Normalize: replace hyphens with underscores and vice versa for matching
|
||||
let alt_name = if name.contains('_') {
|
||||
name.replace('_', "-")
|
||||
} else {
|
||||
name.replace('-', "_")
|
||||
};
|
||||
self.capabilities
|
||||
.all_actions()
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
let a_lower = a.name.to_lowercase();
|
||||
a_lower.contains(&name_lower)
|
||||
|| name_lower.contains(&a_lower)
|
||||
|| a.name == alt_name
|
||||
})
|
||||
.map(|a| a.name.clone())
|
||||
.collect()
|
||||
};
|
||||
serde_json::json!({ "exists": exists, "similar": similar })
|
||||
}
|
||||
|
||||
"list_tools" => {
|
||||
let tools: Vec<serde_json::Value> = self
|
||||
.capabilities
|
||||
.all_actions()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"name": &a.name,
|
||||
"description": &a.description,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "tools": tools, "count": tools.len() })
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(EngineError::Effect {
|
||||
reason: format!("unknown reflection action: {action_name}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: action_name.into(),
|
||||
output,
|
||||
is_error: false,
|
||||
duration: start.elapsed(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn available_actions(
|
||||
&self,
|
||||
_leases: &[CapabilityLease],
|
||||
) -> Result<Vec<ActionDef>, EngineError> {
|
||||
// Reflection tools are always available regardless of leases
|
||||
Ok(Self::action_defs())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the system prompt for a reflection CodeAct thread.
|
||||
pub fn build_reflection_prompt(actions: &[ActionDef], thread_goal: &str) -> String {
|
||||
let mut prompt = String::from(REFLECTION_PREAMBLE);
|
||||
|
||||
prompt.push_str("\n## Available tools (call as Python functions)\n\n");
|
||||
for action in actions {
|
||||
prompt.push_str(&format!("- `{}(", action.name));
|
||||
if let Some(props) = action.parameters_schema.get("properties")
|
||||
&& let Some(obj) = props.as_object()
|
||||
{
|
||||
let params: Vec<&str> = obj.keys().map(String::as_str).collect();
|
||||
prompt.push_str(¶ms.join(", "));
|
||||
}
|
||||
prompt.push_str(&format!(")` — {}\n", action.description));
|
||||
}
|
||||
|
||||
prompt.push_str(&format!(
|
||||
"\n## Thread Under Analysis\n\nGoal: {thread_goal}\n"
|
||||
));
|
||||
|
||||
prompt.push_str(REFLECTION_POSTAMBLE);
|
||||
prompt
|
||||
}
|
||||
|
||||
const REFLECTION_PREAMBLE: &str = "\
|
||||
You are analyzing a completed agent thread to extract structured knowledge. \
|
||||
You have tools to inspect the thread's execution, check existing knowledge, \
|
||||
and verify tool names.
|
||||
|
||||
Write Python code in ```repl blocks to analyze the thread.";
|
||||
|
||||
const REFLECTION_POSTAMBLE: &str = r#"
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Call `get_transcript()` to read the thread's execution history
|
||||
2. Analyze the transcript for: successes, failures, tool errors, lessons learned
|
||||
3. Call `query_memory(query)` to check if similar knowledge already exists
|
||||
4. For any tool errors with "not found", call `check_tool_exists(name)` to find the correct name
|
||||
5. Call `FINAL()` with a JSON object containing a `docs` array:
|
||||
|
||||
```repl
|
||||
FINAL({
|
||||
"docs": [
|
||||
{"type": "summary", "title": "...", "content": "2-4 sentence summary"},
|
||||
{"type": "lesson", "title": "...", "content": "what was learned"},
|
||||
{"type": "spec", "title": "...", "content": "ALIAS: wrong_name -> correct_name"},
|
||||
{"type": "playbook", "title": "...", "content": "1. step one\n2. step two"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Always include a "summary" doc
|
||||
- Include "lesson" only if there were errors or workarounds
|
||||
- Include "spec" only if tool-not-found errors occurred (verify with check_tool_exists)
|
||||
- Include "playbook" only if the thread completed successfully with 2+ tool calls
|
||||
- Skip docs that duplicate existing knowledge (check with query_memory first)
|
||||
- Keep content concise — each doc should be a few sentences, not paragraphs"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::capability::registry::CapabilityRegistry;
|
||||
use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
|
||||
use crate::traits::store::Store;
|
||||
use crate::types::capability::{ActionDef, Capability, CapabilityLease, EffectType, LeaseId};
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::event::ThreadEvent;
|
||||
use crate::types::memory::{DocId, DocType, MemoryDoc};
|
||||
use crate::types::mission::{Mission, MissionId, MissionStatus};
|
||||
use crate::types::project::{Project, ProjectId};
|
||||
use crate::types::step::{Step, StepId};
|
||||
use crate::types::thread::{Thread, ThreadId, ThreadState, ThreadType};
|
||||
|
||||
use super::{ReflectionExecutor, build_reflection_prompt};
|
||||
|
||||
// ── MockStore ──────────────────────────────────────────────
|
||||
|
||||
struct MockStore {
|
||||
docs: tokio::sync::Mutex<Vec<MemoryDoc>>,
|
||||
}
|
||||
|
||||
impl MockStore {
|
||||
fn new(docs: Vec<MemoryDoc>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
docs: tokio::sync::Mutex::new(docs),
|
||||
})
|
||||
}
|
||||
|
||||
fn empty() -> Arc<Self> {
|
||||
Self::new(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Store for MockStore {
|
||||
async fn save_thread(&self, _: &Thread) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_thread(&self, _: ThreadId) -> Result<Option<Thread>, EngineError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_threads(&self, _: ProjectId) -> Result<Vec<Thread>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn update_thread_state(
|
||||
&self,
|
||||
_: ThreadId,
|
||||
_: ThreadState,
|
||||
) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn save_step(&self, _: &Step) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_steps(&self, _: ThreadId) -> Result<Vec<Step>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn append_events(&self, _: &[ThreadEvent]) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_events(&self, _: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn save_project(&self, _: &Project) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_project(&self, _: ProjectId) -> Result<Option<Project>, EngineError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn save_memory_doc(&self, _: &MemoryDoc) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_memory_doc(&self, _: DocId) -> Result<Option<MemoryDoc>, EngineError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_memory_docs(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
) -> Result<Vec<MemoryDoc>, EngineError> {
|
||||
let docs = self.docs.lock().await;
|
||||
Ok(docs
|
||||
.iter()
|
||||
.filter(|d| d.project_id == project_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_active_leases(
|
||||
&self,
|
||||
_: ThreadId,
|
||||
) -> Result<Vec<CapabilityLease>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn revoke_lease(&self, _: LeaseId, _: &str) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn save_mission(&self, _: &Mission) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_mission(&self, _: MissionId) -> Result<Option<Mission>, EngineError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_missions(&self, _: ProjectId) -> Result<Vec<Mission>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn update_mission_status(
|
||||
&self,
|
||||
_: MissionId,
|
||||
_: MissionStatus,
|
||||
) -> Result<(), EngineError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
fn make_lease() -> CapabilityLease {
|
||||
CapabilityLease {
|
||||
id: LeaseId::new(),
|
||||
thread_id: ThreadId::new(),
|
||||
capability_name: "test".into(),
|
||||
granted_actions: vec![],
|
||||
granted_at: Utc::now(),
|
||||
expires_at: None,
|
||||
max_uses: None,
|
||||
uses_remaining: None,
|
||||
revoked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ctx() -> ThreadExecutionContext {
|
||||
ThreadExecutionContext {
|
||||
thread_id: ThreadId::new(),
|
||||
thread_type: ThreadType::Reflection,
|
||||
project_id: ProjectId::new(),
|
||||
user_id: "test".into(),
|
||||
step_id: StepId::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_capability(name: &str, actions: Vec<ActionDef>) -> Capability {
|
||||
Capability {
|
||||
name: name.into(),
|
||||
description: format!("{name} capability"),
|
||||
actions,
|
||||
knowledge: vec![],
|
||||
policies: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_action_def(name: &str) -> ActionDef {
|
||||
ActionDef {
|
||||
name: name.into(),
|
||||
description: format!("{name} action"),
|
||||
parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_transcript_returns_content() {
|
||||
let transcript = "Step 1: called web_search\nStep 2: got results\nDone.";
|
||||
let project_id = ProjectId::new();
|
||||
let executor = ReflectionExecutor::new(
|
||||
MockStore::empty(),
|
||||
Arc::new(CapabilityRegistry::new()),
|
||||
transcript.to_string(),
|
||||
project_id,
|
||||
);
|
||||
|
||||
let lease = make_lease();
|
||||
let ctx = make_ctx();
|
||||
let result = executor
|
||||
.execute_action("get_transcript", serde_json::json!({}), &lease, &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.action_name, "get_transcript");
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.output["transcript"].as_str().unwrap(), transcript);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_memory_finds_docs() {
|
||||
let project_id = ProjectId::new();
|
||||
let docs = vec![
|
||||
MemoryDoc::new(
|
||||
project_id,
|
||||
DocType::Lesson,
|
||||
"deployment error",
|
||||
"Fix: restart the service",
|
||||
),
|
||||
MemoryDoc::new(
|
||||
project_id,
|
||||
DocType::Summary,
|
||||
"weather check",
|
||||
"Fetched weather data",
|
||||
),
|
||||
];
|
||||
let store = MockStore::new(docs);
|
||||
let executor = ReflectionExecutor::new(
|
||||
store,
|
||||
Arc::new(CapabilityRegistry::new()),
|
||||
String::new(),
|
||||
project_id,
|
||||
);
|
||||
|
||||
let lease = make_lease();
|
||||
let ctx = make_ctx();
|
||||
let result = executor
|
||||
.execute_action(
|
||||
"query_memory",
|
||||
serde_json::json!({"query": "deployment error", "max_docs": 5}),
|
||||
&lease,
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error);
|
||||
let count = result.output["count"].as_u64().unwrap();
|
||||
assert!(count >= 1, "expected at least 1 doc, got {count}");
|
||||
|
||||
let docs_arr = result.output["docs"].as_array().unwrap();
|
||||
// The deployment error doc should be present
|
||||
let has_deployment = docs_arr
|
||||
.iter()
|
||||
.any(|d| d["title"].as_str().unwrap().contains("deployment"));
|
||||
assert!(has_deployment, "expected deployment doc in results");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_exists_found() {
|
||||
let mut registry = CapabilityRegistry::new();
|
||||
registry.register(make_capability(
|
||||
"search",
|
||||
vec![make_action_def("web-search")],
|
||||
));
|
||||
|
||||
let project_id = ProjectId::new();
|
||||
let executor = ReflectionExecutor::new(
|
||||
MockStore::empty(),
|
||||
Arc::new(registry),
|
||||
String::new(),
|
||||
project_id,
|
||||
);
|
||||
|
||||
let lease = make_lease();
|
||||
let ctx = make_ctx();
|
||||
let result = executor
|
||||
.execute_action(
|
||||
"check_tool_exists",
|
||||
serde_json::json!({"name": "web-search"}),
|
||||
&lease,
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.output["exists"], true);
|
||||
assert!(result.output["similar"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_exists_not_found_suggests_similar() {
|
||||
let mut registry = CapabilityRegistry::new();
|
||||
registry.register(make_capability(
|
||||
"search",
|
||||
vec![make_action_def("web-search")],
|
||||
));
|
||||
|
||||
let project_id = ProjectId::new();
|
||||
let executor = ReflectionExecutor::new(
|
||||
MockStore::empty(),
|
||||
Arc::new(registry),
|
||||
String::new(),
|
||||
project_id,
|
||||
);
|
||||
|
||||
let lease = make_lease();
|
||||
let ctx = make_ctx();
|
||||
let result = executor
|
||||
.execute_action(
|
||||
"check_tool_exists",
|
||||
serde_json::json!({"name": "web_search"}),
|
||||
&lease,
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.output["exists"], false);
|
||||
let similar: Vec<String> = result.output["similar"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
similar.contains(&"web-search".to_string()),
|
||||
"expected 'web-search' in similar list, got: {similar:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tools_returns_all() {
|
||||
let mut registry = CapabilityRegistry::new();
|
||||
registry.register(make_capability(
|
||||
"search",
|
||||
vec![
|
||||
make_action_def("web-search"),
|
||||
make_action_def("memory-search"),
|
||||
],
|
||||
));
|
||||
registry.register(make_capability("files", vec![make_action_def("read-file")]));
|
||||
|
||||
let project_id = ProjectId::new();
|
||||
let executor = ReflectionExecutor::new(
|
||||
MockStore::empty(),
|
||||
Arc::new(registry),
|
||||
String::new(),
|
||||
project_id,
|
||||
);
|
||||
|
||||
let lease = make_lease();
|
||||
let ctx = make_ctx();
|
||||
let result = executor
|
||||
.execute_action("list_tools", serde_json::json!({}), &lease, &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.output["count"].as_u64().unwrap(), 3);
|
||||
let tools = result.output["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"web-search"));
|
||||
assert!(names.contains(&"memory-search"));
|
||||
assert!(names.contains(&"read-file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_reflection_prompt_includes_tools() {
|
||||
let actions = vec![
|
||||
ActionDef {
|
||||
name: "get_transcript".into(),
|
||||
description: "Get the execution transcript".into(),
|
||||
parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
ActionDef {
|
||||
name: "query_memory".into(),
|
||||
description: "Search memory docs".into(),
|
||||
parameters_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"max_docs": {"type": "integer"}
|
||||
}
|
||||
}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
},
|
||||
];
|
||||
|
||||
let prompt = build_reflection_prompt(&actions, "analyze deployment failure");
|
||||
assert!(
|
||||
prompt.contains("get_transcript"),
|
||||
"prompt should contain get_transcript tool name"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("query_memory"),
|
||||
"prompt should contain query_memory tool name"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("analyze deployment failure"),
|
||||
"prompt should contain the thread goal"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Available tools"),
|
||||
"prompt should contain the tools section header"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! Post-thread reflection pipeline.
|
||||
//!
|
||||
//! After a thread completes, [`reflect()`] spawns a CodeAct thread with
|
||||
//! reflection-specific tools to produce structured knowledge (MemoryDocs):
|
||||
//! - Summary — what the thread accomplished
|
||||
//! - Lesson — what was learned from errors/workarounds
|
||||
//! - Issue — unresolved problems for follow-up
|
||||
//! - Spec — missing capabilities / tool alias suggestions
|
||||
//! - Playbook — reusable multi-step procedures from successful threads
|
||||
//!
|
||||
//! The reflection thread can introspect the completed thread's transcript,
|
||||
//! query existing knowledge, and verify tool names against the capability
|
||||
//! registry. [`reflect_simple()`] is a fallback using direct LLM calls.
|
||||
|
||||
pub mod executor;
|
||||
pub mod pipeline;
|
||||
|
||||
pub use pipeline::{ReflectionResult, reflect, reflect_simple};
|
||||
@@ -1,682 +0,0 @@
|
||||
//! Reflection pipeline — produces structured knowledge from completed threads.
|
||||
//!
|
||||
//! After a thread completes, the reflection pipeline spawns a CodeAct thread
|
||||
//! that uses reflection-specific tools (transcript inspection, memory queries,
|
||||
//! tool registry checks) to produce structured MemoryDocs.
|
||||
//!
|
||||
//! The reflection thread runs with [`ThreadType::Reflection`] and its own
|
||||
//! [`ExecutionLoop`], making it a fully recursive CodeAct agent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::capability::lease::LeaseManager;
|
||||
use crate::capability::policy::PolicyEngine;
|
||||
use crate::capability::registry::CapabilityRegistry;
|
||||
use crate::executor::ExecutionLoop;
|
||||
use crate::reflection::executor::{ReflectionExecutor, build_reflection_prompt};
|
||||
use crate::runtime::messaging::{self, ThreadOutcome};
|
||||
use crate::traits::llm::LlmBackend;
|
||||
use crate::traits::store::Store;
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::event::EventKind;
|
||||
use crate::types::memory::{DocType, MemoryDoc};
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::step::TokenUsage;
|
||||
use crate::types::thread::{Thread, ThreadConfig, ThreadType};
|
||||
|
||||
/// Result of running the reflection pipeline on a completed thread.
|
||||
pub struct ReflectionResult {
|
||||
/// Memory docs produced by reflection.
|
||||
pub docs: Vec<MemoryDoc>,
|
||||
/// Total tokens used by reflection LLM calls.
|
||||
pub tokens_used: TokenUsage,
|
||||
}
|
||||
|
||||
/// Run the reflection pipeline on a completed thread.
|
||||
///
|
||||
/// Spawns a CodeAct thread with reflection-specific tools that can:
|
||||
/// - Read the completed thread's execution transcript
|
||||
/// - Query existing knowledge in the project
|
||||
/// - Verify tool names against the capability registry
|
||||
///
|
||||
/// The reflection thread produces structured findings via `FINAL()` which
|
||||
/// are parsed into MemoryDocs.
|
||||
pub async fn reflect(
|
||||
thread: &Thread,
|
||||
llm: &Arc<dyn LlmBackend>,
|
||||
store: &Arc<dyn Store>,
|
||||
capabilities: &Arc<CapabilityRegistry>,
|
||||
) -> Result<ReflectionResult, EngineError> {
|
||||
let transcript = build_transcript(thread);
|
||||
|
||||
// Build the reflection-specific effect executor
|
||||
let executor: Arc<dyn crate::traits::effect::EffectExecutor> =
|
||||
Arc::new(ReflectionExecutor::new(
|
||||
Arc::clone(store),
|
||||
Arc::clone(capabilities),
|
||||
transcript,
|
||||
thread.project_id,
|
||||
));
|
||||
|
||||
// Create a reflection thread
|
||||
let mut refl_thread = Thread::new(
|
||||
format!("Reflect on: {}", thread.goal),
|
||||
ThreadType::Reflection,
|
||||
thread.project_id,
|
||||
ThreadConfig {
|
||||
max_iterations: 10,
|
||||
enable_reflection: false, // no recursive reflection
|
||||
..ThreadConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
// Build and inject the reflection system prompt
|
||||
let actions = executor.available_actions(&[]).await?;
|
||||
let system_prompt = build_reflection_prompt(&actions, &thread.goal);
|
||||
refl_thread
|
||||
.messages
|
||||
.insert(0, ThreadMessage::system(system_prompt));
|
||||
refl_thread.add_message(ThreadMessage::user(format!(
|
||||
"Analyze the completed thread '{}' and produce structured findings.",
|
||||
thread.goal
|
||||
)));
|
||||
|
||||
// Set up infrastructure for the reflection loop
|
||||
let lease_manager = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
let (_signal_tx, signal_rx) = messaging::signal_channel(32);
|
||||
|
||||
// Grant a blanket lease (empty granted_actions = all actions allowed)
|
||||
let lease = lease_manager
|
||||
.grant(refl_thread.id, "reflection_tools", vec![], None, None)
|
||||
.await;
|
||||
refl_thread.capability_leases.push(lease.id);
|
||||
store.save_thread(&refl_thread).await?;
|
||||
store.save_lease(&lease).await?;
|
||||
|
||||
// Run the execution loop
|
||||
let mut exec_loop = ExecutionLoop::new(
|
||||
refl_thread,
|
||||
Arc::clone(llm),
|
||||
executor,
|
||||
lease_manager,
|
||||
policy,
|
||||
signal_rx,
|
||||
"system".to_string(),
|
||||
)
|
||||
.with_store(Arc::clone(store));
|
||||
|
||||
let outcome = exec_loop.run().await?;
|
||||
|
||||
// Parse the outcome into MemoryDocs
|
||||
let response = match outcome {
|
||||
ThreadOutcome::Completed { response: Some(r) } => r,
|
||||
ThreadOutcome::Completed { response: None } => String::new(),
|
||||
ThreadOutcome::Failed { error } => {
|
||||
warn!(
|
||||
thread_id = %thread.id,
|
||||
"reflection thread failed: {error}"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let docs = parse_reflection_output(&response, thread);
|
||||
let tokens_used = TokenUsage {
|
||||
input_tokens: exec_loop.thread.total_tokens_used,
|
||||
output_tokens: 0, // total already tracked
|
||||
..TokenUsage::default()
|
||||
};
|
||||
|
||||
debug!(
|
||||
thread_id = %thread.id,
|
||||
docs_produced = docs.len(),
|
||||
total_tokens = tokens_used.total(),
|
||||
"reflection complete (CodeAct)"
|
||||
);
|
||||
|
||||
Ok(ReflectionResult { docs, tokens_used })
|
||||
}
|
||||
|
||||
/// Run a simplified reflection pipeline using direct LLM calls.
|
||||
///
|
||||
/// This is a fallback for when CodeAct execution is not available or when
|
||||
/// the reflection thread overhead is not desired (e.g., in tests).
|
||||
pub async fn reflect_simple(
|
||||
thread: &Thread,
|
||||
llm: &Arc<dyn LlmBackend>,
|
||||
) -> Result<ReflectionResult, EngineError> {
|
||||
let mut docs = Vec::new();
|
||||
let mut total_tokens = TokenUsage::default();
|
||||
let transcript = build_transcript(thread);
|
||||
|
||||
// 1. Summary doc
|
||||
let (summary_doc, tokens) =
|
||||
produce_doc(thread, llm, DocType::Summary, &transcript, SUMMARY_PROMPT).await?;
|
||||
docs.push(summary_doc);
|
||||
total_tokens.input_tokens += tokens.input_tokens;
|
||||
total_tokens.output_tokens += tokens.output_tokens;
|
||||
|
||||
// 2. Lessons (only if there were errors)
|
||||
let had_errors = thread.events.iter().any(|e| {
|
||||
matches!(
|
||||
e.kind,
|
||||
EventKind::ActionFailed { .. } | EventKind::StepFailed { .. }
|
||||
)
|
||||
});
|
||||
if had_errors {
|
||||
let (lesson_doc, tokens) =
|
||||
produce_doc(thread, llm, DocType::Lesson, &transcript, LESSON_PROMPT).await?;
|
||||
docs.push(lesson_doc);
|
||||
total_tokens.input_tokens += tokens.input_tokens;
|
||||
total_tokens.output_tokens += tokens.output_tokens;
|
||||
}
|
||||
|
||||
// 3. Issues (if thread failed or had unresolved problems)
|
||||
let thread_failed = thread.state == crate::types::thread::ThreadState::Failed;
|
||||
if thread_failed || had_errors {
|
||||
let (issue_doc, tokens) =
|
||||
produce_doc(thread, llm, DocType::Issue, &transcript, ISSUE_PROMPT).await?;
|
||||
if issue_doc.content.chars().count() > 20 {
|
||||
docs.push(issue_doc);
|
||||
}
|
||||
total_tokens.input_tokens += tokens.input_tokens;
|
||||
total_tokens.output_tokens += tokens.output_tokens;
|
||||
}
|
||||
|
||||
// 4. Missing capabilities
|
||||
let has_missing_tools = thread.events.iter().any(|e| {
|
||||
if let EventKind::ActionFailed { error, .. } = &e.kind {
|
||||
error.contains("not found") || error.contains("not available")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if has_missing_tools {
|
||||
let (spec_doc, tokens) =
|
||||
produce_doc(thread, llm, DocType::Spec, &transcript, SPEC_PROMPT).await?;
|
||||
if spec_doc.content.chars().count() > 20 {
|
||||
docs.push(spec_doc);
|
||||
}
|
||||
total_tokens.input_tokens += tokens.input_tokens;
|
||||
total_tokens.output_tokens += tokens.output_tokens;
|
||||
}
|
||||
|
||||
// 5. Playbook
|
||||
let action_count = thread
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EventKind::ActionExecuted { .. }))
|
||||
.count();
|
||||
let thread_succeeded =
|
||||
thread.state == crate::types::thread::ThreadState::Completed && !thread_failed;
|
||||
if thread_succeeded && action_count >= 2 {
|
||||
let (playbook_doc, tokens) =
|
||||
produce_doc(thread, llm, DocType::Playbook, &transcript, PLAYBOOK_PROMPT).await?;
|
||||
if playbook_doc.content.chars().count() > 20 {
|
||||
docs.push(playbook_doc);
|
||||
}
|
||||
total_tokens.input_tokens += tokens.input_tokens;
|
||||
total_tokens.output_tokens += tokens.output_tokens;
|
||||
}
|
||||
|
||||
debug!(
|
||||
thread_id = %thread.id,
|
||||
docs_produced = docs.len(),
|
||||
total_tokens = total_tokens.total(),
|
||||
"reflection complete (simple)"
|
||||
);
|
||||
|
||||
Ok(ReflectionResult {
|
||||
docs,
|
||||
tokens_used: total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Output parsing ────────────────────────────────────────────
|
||||
|
||||
/// Parse the FINAL() output from a reflection CodeAct thread into MemoryDocs.
|
||||
fn parse_reflection_output(response: &str, source_thread: &Thread) -> Vec<MemoryDoc> {
|
||||
// Try parsing as JSON first (the expected format)
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(response)
|
||||
&& let Some(docs_arr) = value.get("docs").and_then(|d| d.as_array())
|
||||
{
|
||||
return docs_arr
|
||||
.iter()
|
||||
.filter_map(|doc_val| parse_doc_entry(doc_val, source_thread))
|
||||
.collect();
|
||||
}
|
||||
|
||||
// If the response is not valid JSON, try to find JSON in the response
|
||||
if let Some(start) = response.find('{')
|
||||
&& let Some(end) = response.rfind('}')
|
||||
{
|
||||
let json_str = &response[start..=end];
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json_str)
|
||||
&& let Some(docs_arr) = value.get("docs").and_then(|d| d.as_array())
|
||||
{
|
||||
return docs_arr
|
||||
.iter()
|
||||
.filter_map(|doc_val| parse_doc_entry(doc_val, source_thread))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: treat the entire response as a summary
|
||||
if response.chars().count() > 20 {
|
||||
vec![
|
||||
MemoryDoc::new(
|
||||
source_thread.project_id,
|
||||
DocType::Summary,
|
||||
format!("Summary: {}", source_thread.goal),
|
||||
response,
|
||||
)
|
||||
.with_source_thread(source_thread.id),
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single doc entry from the JSON output.
|
||||
fn parse_doc_entry(value: &serde_json::Value, source_thread: &Thread) -> Option<MemoryDoc> {
|
||||
let doc_type_str = value.get("type")?.as_str()?;
|
||||
let title = value.get("title")?.as_str()?;
|
||||
let content = value.get("content")?.as_str()?;
|
||||
|
||||
if content.chars().count() <= 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let doc_type = match doc_type_str.to_lowercase().as_str() {
|
||||
"summary" => DocType::Summary,
|
||||
"lesson" => DocType::Lesson,
|
||||
"issue" => DocType::Issue,
|
||||
"spec" => DocType::Spec,
|
||||
"playbook" => DocType::Playbook,
|
||||
"note" => DocType::Note,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(
|
||||
MemoryDoc::new(source_thread.project_id, doc_type, title, content)
|
||||
.with_source_thread(source_thread.id),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prompts (for reflect_simple fallback) ─────────────────────
|
||||
|
||||
const SUMMARY_PROMPT: &str = "\
|
||||
Summarize what this thread accomplished in 2-4 sentences. Include:
|
||||
- The goal and whether it was achieved
|
||||
- Key results or outputs
|
||||
- Tools/actions that were used
|
||||
Be factual and concise.";
|
||||
|
||||
const LESSON_PROMPT: &str = "\
|
||||
Extract lessons learned from this thread's execution. Focus on:
|
||||
- Errors encountered and how they were resolved (or not)
|
||||
- Workarounds that were discovered
|
||||
- Surprising findings about tool behavior
|
||||
- Patterns that could be reused in similar tasks
|
||||
Write each lesson as a single clear sentence. If there are no meaningful lessons, write 'No lessons.'.";
|
||||
|
||||
const ISSUE_PROMPT: &str = "\
|
||||
Identify any unresolved issues from this thread. Focus on:
|
||||
- Errors that were not resolved
|
||||
- Tasks that could not be completed
|
||||
- Missing tools or capabilities that were needed
|
||||
- Data quality issues encountered
|
||||
If there are no unresolved issues, write 'No issues.'.";
|
||||
|
||||
const SPEC_PROMPT: &str = "\
|
||||
This thread encountered missing tools or capabilities. Analyze the errors and identify:
|
||||
- Which tool names were attempted but not found
|
||||
- What the correct tool name might be (if a similar tool exists under a different name)
|
||||
- What capabilities would need to be added to handle this task
|
||||
For each missing capability, write one line: MISSING: <attempted_name> -> <suggestion or description>.
|
||||
If the tool exists under a different name, write: ALIAS: <attempted_name> -> <correct_name>.";
|
||||
|
||||
const PLAYBOOK_PROMPT: &str = "\
|
||||
This thread successfully completed a multi-step task. Extract a reusable playbook:
|
||||
- List the steps taken in order (tool calls, queries, transformations)
|
||||
- Note which tools were used and in what sequence
|
||||
- Describe the pattern so it can be reused for similar tasks
|
||||
Write the playbook as a numbered list of steps. Be specific about tool names and parameters used.";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Build a concise transcript of the thread's work.
|
||||
pub(crate) fn build_transcript(thread: &Thread) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
parts.push(format!("Goal: {}", thread.goal));
|
||||
parts.push(format!("Steps: {}", thread.step_count));
|
||||
parts.push(format!("Tokens used: {}", thread.total_tokens_used));
|
||||
parts.push(format!("State: {:?}", thread.state));
|
||||
|
||||
// Include messages (truncated for very long threads)
|
||||
let max_messages = 30;
|
||||
let messages = if thread.messages.len() > max_messages {
|
||||
&thread.messages[thread.messages.len() - max_messages..]
|
||||
} else {
|
||||
&thread.messages
|
||||
};
|
||||
|
||||
parts.push("\n--- Messages ---".into());
|
||||
for msg in messages {
|
||||
let role = format!("{:?}", msg.role);
|
||||
let content_preview: String = msg.content.chars().take(500).collect();
|
||||
let truncated = if msg.content.chars().count() > 500 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
parts.push(format!("[{role}] {content_preview}{truncated}"));
|
||||
}
|
||||
|
||||
// Include notable events
|
||||
let error_events: Vec<String> = thread
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EventKind::ActionFailed {
|
||||
action_name, error, ..
|
||||
} => Some(format!("Action '{action_name}' failed: {error}")),
|
||||
EventKind::StepFailed { error, .. } => Some(format!("Step failed: {error}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !error_events.is_empty() {
|
||||
parts.push("\n--- Errors ---".into());
|
||||
for err in error_events {
|
||||
parts.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Produce a single MemoryDoc by asking the LLM to analyze the transcript.
|
||||
async fn produce_doc(
|
||||
thread: &Thread,
|
||||
llm: &Arc<dyn LlmBackend>,
|
||||
doc_type: DocType,
|
||||
transcript: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(MemoryDoc, TokenUsage), EngineError> {
|
||||
let messages = vec![
|
||||
ThreadMessage::system(format!(
|
||||
"You are analyzing a completed agent thread. Here is the transcript:\n\n{transcript}"
|
||||
)),
|
||||
ThreadMessage::user(prompt.to_string()),
|
||||
];
|
||||
|
||||
let config = crate::traits::llm::LlmCallConfig {
|
||||
force_text: true,
|
||||
..crate::traits::llm::LlmCallConfig::default()
|
||||
};
|
||||
|
||||
let output = llm.complete(&messages, &[], &config).await?;
|
||||
|
||||
let content = match output.response {
|
||||
crate::types::step::LlmResponse::Text(t) => t,
|
||||
crate::types::step::LlmResponse::ActionCalls { content, .. }
|
||||
| crate::types::step::LlmResponse::Code { content, .. } => content.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let title = match doc_type {
|
||||
DocType::Summary => format!("Summary: {}", thread.goal),
|
||||
DocType::Lesson => format!("Lessons: {}", thread.goal),
|
||||
DocType::Issue => format!("Issues: {}", thread.goal),
|
||||
DocType::Playbook => format!("Playbook: {}", thread.goal),
|
||||
DocType::Spec => format!("Spec: {}", thread.goal),
|
||||
DocType::Note => format!("Note: {}", thread.goal),
|
||||
};
|
||||
|
||||
let doc =
|
||||
MemoryDoc::new(thread.project_id, doc_type, title, content).with_source_thread(thread.id);
|
||||
|
||||
Ok((doc, output.usage))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::llm::{LlmCallConfig, LlmOutput};
|
||||
use crate::types::capability::ActionDef;
|
||||
use crate::types::event::ThreadEvent;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::TokenUsage;
|
||||
use crate::types::thread::ThreadConfig;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockLlm {
|
||||
responses: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MockLlm {
|
||||
fn with_responses(responses: Vec<&str>) -> Arc<dyn crate::traits::llm::LlmBackend> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(String::from).collect()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::traits::llm::LlmBackend for MockLlm {
|
||||
async fn complete(
|
||||
&self,
|
||||
_: &[ThreadMessage],
|
||||
_: &[ActionDef],
|
||||
_: &LlmCallConfig,
|
||||
) -> Result<LlmOutput, EngineError> {
|
||||
let mut r = self.responses.lock().unwrap();
|
||||
let text = if r.is_empty() {
|
||||
"mock response".to_string()
|
||||
} else {
|
||||
r.remove(0)
|
||||
};
|
||||
Ok(LlmOutput {
|
||||
response: crate::types::step::LlmResponse::Text(text),
|
||||
usage: TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
..TokenUsage::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn model_name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
}
|
||||
|
||||
fn make_completed_thread() -> Thread {
|
||||
let mut thread = Thread::new(
|
||||
"test task",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
thread.state = crate::types::thread::ThreadState::Completed;
|
||||
thread
|
||||
}
|
||||
|
||||
// ── reflect_simple tests (direct LLM calls) ────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflect_simple_produces_summary() {
|
||||
let thread = make_completed_thread();
|
||||
let llm = MockLlm::with_responses(vec!["Thread accomplished the test task successfully."]);
|
||||
|
||||
let result = reflect_simple(&thread, &llm).await.unwrap();
|
||||
assert_eq!(result.docs.len(), 1);
|
||||
assert_eq!(result.docs[0].doc_type, DocType::Summary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflect_simple_produces_lesson_on_errors() {
|
||||
let mut thread = make_completed_thread();
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionFailed {
|
||||
step_id: crate::types::step::StepId::new(),
|
||||
action_name: "web_search".into(),
|
||||
call_id: String::new(),
|
||||
error: "Tool web_search not found".into(),
|
||||
},
|
||||
));
|
||||
|
||||
let llm = MockLlm::with_responses(vec![
|
||||
"Summary of thread with errors.",
|
||||
"Lesson: use web-search instead of web_search.",
|
||||
"Issue: web_search tool is missing.",
|
||||
"ALIAS: web_search -> web-search",
|
||||
]);
|
||||
|
||||
let result = reflect_simple(&thread, &llm).await.unwrap();
|
||||
let types: Vec<DocType> = result.docs.iter().map(|d| d.doc_type).collect();
|
||||
assert!(types.contains(&DocType::Summary));
|
||||
assert!(types.contains(&DocType::Lesson));
|
||||
assert!(types.contains(&DocType::Issue));
|
||||
assert!(types.contains(&DocType::Spec));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflect_simple_produces_spec_on_tool_not_found() {
|
||||
let mut thread = make_completed_thread();
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionFailed {
|
||||
step_id: crate::types::step::StepId::new(),
|
||||
action_name: "missing_tool".into(),
|
||||
call_id: String::new(),
|
||||
error: "Tool missing_tool not found".into(),
|
||||
},
|
||||
));
|
||||
|
||||
let llm = MockLlm::with_responses(vec![
|
||||
"Summary.",
|
||||
"Lesson learned.",
|
||||
"Issues found.",
|
||||
"MISSING: missing_tool -> needs implementation",
|
||||
]);
|
||||
|
||||
let result = reflect_simple(&thread, &llm).await.unwrap();
|
||||
let spec_docs: Vec<&MemoryDoc> = result
|
||||
.docs
|
||||
.iter()
|
||||
.filter(|d| d.doc_type == DocType::Spec)
|
||||
.collect();
|
||||
assert_eq!(spec_docs.len(), 1);
|
||||
assert!(spec_docs[0].content.contains("MISSING"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflect_simple_produces_playbook_on_multi_step() {
|
||||
let mut thread = make_completed_thread();
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionExecuted {
|
||||
step_id: crate::types::step::StepId::new(),
|
||||
action_name: "web-search".into(),
|
||||
call_id: String::new(),
|
||||
duration_ms: 100,
|
||||
},
|
||||
));
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionExecuted {
|
||||
step_id: crate::types::step::StepId::new(),
|
||||
action_name: "llm_query".into(),
|
||||
call_id: String::new(),
|
||||
duration_ms: 200,
|
||||
},
|
||||
));
|
||||
|
||||
let llm = MockLlm::with_responses(vec![
|
||||
"Summary of successful thread.",
|
||||
"1. Search web\n2. Analyze results\n3. Return summary",
|
||||
]);
|
||||
|
||||
let result = reflect_simple(&thread, &llm).await.unwrap();
|
||||
assert!(result.docs.iter().any(|d| d.doc_type == DocType::Playbook));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflect_simple_skips_playbook_for_single_action() {
|
||||
let mut thread = make_completed_thread();
|
||||
thread.events.push(ThreadEvent::new(
|
||||
thread.id,
|
||||
EventKind::ActionExecuted {
|
||||
step_id: crate::types::step::StepId::new(),
|
||||
action_name: "echo".into(),
|
||||
call_id: String::new(),
|
||||
duration_ms: 5,
|
||||
},
|
||||
));
|
||||
|
||||
let llm = MockLlm::with_responses(vec!["Simple summary."]);
|
||||
|
||||
let result = reflect_simple(&thread, &llm).await.unwrap();
|
||||
assert!(!result.docs.iter().any(|d| d.doc_type == DocType::Playbook));
|
||||
}
|
||||
|
||||
// ── parse_reflection_output tests ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_valid_json_output() {
|
||||
let thread = make_completed_thread();
|
||||
let json = r#"{"docs": [
|
||||
{"type": "summary", "title": "Summary: test", "content": "The thread completed successfully with good results."},
|
||||
{"type": "lesson", "title": "Lesson: test", "content": "Always check tool names before calling them."}
|
||||
]}"#;
|
||||
|
||||
let docs = parse_reflection_output(json, &thread);
|
||||
assert_eq!(docs.len(), 2);
|
||||
assert_eq!(docs[0].doc_type, DocType::Summary);
|
||||
assert_eq!(docs[1].doc_type, DocType::Lesson);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_embedded_in_text() {
|
||||
let thread = make_completed_thread();
|
||||
let text = r#"Here are my findings: {"docs": [{"type": "summary", "title": "test", "content": "The thread did something interesting and useful."}]} end"#;
|
||||
|
||||
let docs = parse_reflection_output(text, &thread);
|
||||
assert_eq!(docs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fallback_to_summary() {
|
||||
let thread = make_completed_thread();
|
||||
let text = "This is a plain text response with enough content to be a valid summary doc.";
|
||||
|
||||
let docs = parse_reflection_output(text, &thread);
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(docs[0].doc_type, DocType::Summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_short_content() {
|
||||
let thread = make_completed_thread();
|
||||
let json = r#"{"docs": [{"type": "summary", "title": "test", "content": "too short"}]}"#;
|
||||
|
||||
let docs = parse_reflection_output(json, &thread);
|
||||
assert!(docs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_unknown_doc_type() {
|
||||
let thread = make_completed_thread();
|
||||
let json = r#"{"docs": [{"type": "unknown_type", "title": "test", "content": "This has enough content but unknown type so it gets skipped."}]}"#;
|
||||
|
||||
let docs = parse_reflection_output(json, &thread);
|
||||
assert!(docs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -245,9 +245,6 @@ impl ThreadManager {
|
||||
|
||||
// Spawn background task
|
||||
let store_for_task = Arc::clone(&self.store);
|
||||
let llm_for_reflection = Arc::clone(&self.llm);
|
||||
let caps_for_reflection = Arc::clone(&self.capabilities);
|
||||
let event_tx = self.event_tx.clone();
|
||||
let running = Arc::clone(&self.running);
|
||||
let completed = Arc::clone(&self.completed);
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -255,98 +252,24 @@ impl ThreadManager {
|
||||
let result = exec.run().await;
|
||||
debug!(thread_id = %thread_id, "thread execution finished");
|
||||
|
||||
// Helper to emit events on both the thread and broadcast channel
|
||||
let emit = |thread: &mut crate::types::thread::Thread,
|
||||
kind: crate::types::event::EventKind| {
|
||||
let event = crate::types::event::ThreadEvent::new(thread.id, kind);
|
||||
let _ = event_tx.send(event.clone());
|
||||
thread.events.push(event);
|
||||
thread.updated_at = chrono::Utc::now();
|
||||
};
|
||||
|
||||
// Run retrospective trace analysis (non-LLM, always runs)
|
||||
let mut trace = crate::executor::trace::build_trace(&exec.thread);
|
||||
// Run retrospective trace analysis (non-LLM, always runs).
|
||||
// Issues are picked up by the self-improvement mission via event listener.
|
||||
let trace = crate::executor::trace::build_trace(&exec.thread);
|
||||
if !trace.issues.is_empty() {
|
||||
crate::executor::trace::log_trace_summary(&trace);
|
||||
}
|
||||
|
||||
// Run LLM reflection if enabled and thread completed
|
||||
if exec.thread.config.enable_reflection
|
||||
&& exec.thread.state == crate::types::thread::ThreadState::Completed
|
||||
// Transition Completed → Done
|
||||
if exec.thread.state == crate::types::thread::ThreadState::Completed
|
||||
&& let Err(e) = exec.thread.transition_to(
|
||||
crate::types::thread::ThreadState::Done,
|
||||
None,
|
||||
)
|
||||
{
|
||||
// Transition: Completed → Reflecting
|
||||
if let Err(e) = exec.thread.transition_to(
|
||||
crate::types::thread::ThreadState::Reflecting,
|
||||
Some("starting reflection".into()),
|
||||
) {
|
||||
tracing::warn!(thread_id = %thread_id, "failed to transition to Reflecting: {e}");
|
||||
} else {
|
||||
emit(
|
||||
&mut exec.thread,
|
||||
crate::types::event::EventKind::ReflectionStarted,
|
||||
);
|
||||
|
||||
match crate::reflection::reflect(
|
||||
&exec.thread,
|
||||
&llm_for_reflection,
|
||||
&store_for_task,
|
||||
&caps_for_reflection,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reflection) => {
|
||||
let doc_types: Vec<String> = reflection
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| format!("{:?}", d.doc_type))
|
||||
.collect();
|
||||
|
||||
emit(
|
||||
&mut exec.thread,
|
||||
crate::types::event::EventKind::ReflectionComplete {
|
||||
docs_produced: reflection.docs.len(),
|
||||
doc_types,
|
||||
tokens_used: reflection.tokens_used.total(),
|
||||
},
|
||||
);
|
||||
|
||||
// Attach reflection results to the trace
|
||||
crate::executor::trace::attach_reflection(&mut trace, &reflection);
|
||||
|
||||
for doc in &reflection.docs {
|
||||
if let Err(e) = store_for_task.save_memory_doc(doc).await {
|
||||
tracing::warn!(
|
||||
thread_id = %thread_id,
|
||||
doc_title = %doc.title,
|
||||
"failed to save reflection doc: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
emit(
|
||||
&mut exec.thread,
|
||||
crate::types::event::EventKind::ReflectionFailed {
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Transition: Reflecting → Done
|
||||
if let Err(e) = exec.thread.transition_to(
|
||||
crate::types::thread::ThreadState::Done,
|
||||
Some("reflection finished".into()),
|
||||
) {
|
||||
tracing::warn!(
|
||||
thread_id = %thread_id,
|
||||
"failed to transition to Done after reflection: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
tracing::warn!(thread_id = %thread_id, "failed to transition to Done: {e}");
|
||||
}
|
||||
|
||||
// Write trace file if enabled (after reflection, so it's included)
|
||||
// Write trace file if enabled
|
||||
if crate::executor::trace::is_trace_enabled() {
|
||||
crate::executor::trace::log_trace_summary(&trace);
|
||||
crate::executor::trace::write_trace(&trace);
|
||||
|
||||
@@ -146,10 +146,7 @@ impl MissionManager {
|
||||
&meta_prompt,
|
||||
ThreadType::Mission,
|
||||
mission.project_id,
|
||||
ThreadConfig {
|
||||
enable_reflection: true,
|
||||
..ThreadConfig::default()
|
||||
},
|
||||
ThreadConfig::default(),
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
@@ -270,51 +267,62 @@ impl MissionManager {
|
||||
Ok(spawned)
|
||||
}
|
||||
|
||||
/// Start a background event listener that fires `OnSystemEvent` missions
|
||||
/// when threads complete with issues.
|
||||
/// Start a background event listener that fires learning missions when
|
||||
/// threads complete.
|
||||
///
|
||||
/// Subscribes to the ThreadManager's event broadcast channel and watches
|
||||
/// for thread completion events. When a non-Mission, non-Reflection thread
|
||||
/// completes and its trace has issues, fires matching OnSystemEvent missions
|
||||
/// with trace data as the trigger payload.
|
||||
/// for `StateChanged { to: Done }`. For each completed non-Mission thread:
|
||||
///
|
||||
/// 1. **Error diagnosis** — if trace has issues, fires `thread_completed_with_issues`
|
||||
/// 2. **Playbook extraction** — if thread succeeded with many steps/actions,
|
||||
/// fires `thread_completed_with_learnings`
|
||||
/// 3. **Conversation insights** — after every N threads in a conversation,
|
||||
/// fires `conversation_insights_due`
|
||||
pub fn start_event_listener(self: &Arc<Self>, user_id: String) {
|
||||
let mgr = Arc::clone(self);
|
||||
let mut rx = mgr.thread_manager.subscribe_events();
|
||||
|
||||
/// Minimum steps for a thread to be a playbook candidate.
|
||||
const PLAYBOOK_MIN_STEPS: usize = 5;
|
||||
/// Minimum distinct action executions for playbook extraction.
|
||||
const PLAYBOOK_MIN_ACTIONS: usize = 3;
|
||||
/// Completed thread interval for conversation insights.
|
||||
const CONVERSATION_INSIGHTS_INTERVAL: u32 = 5;
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Track completed thread count per conversation for insights trigger.
|
||||
let mut conv_thread_counts: std::collections::HashMap<String, u32> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// React to ReflectionComplete — the thread is done and
|
||||
// we have reflection doc info for the trigger payload.
|
||||
if let crate::types::event::EventKind::ReflectionComplete {
|
||||
docs_produced,
|
||||
ref doc_types,
|
||||
..
|
||||
} = event.kind
|
||||
{
|
||||
// Load the thread to check its type and build the payload
|
||||
let thread = mgr.store.load_thread(event.thread_id).await;
|
||||
let thread = match thread {
|
||||
Ok(Some(t)) => t,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Skip Mission and Reflection threads (no recursive self-improvement)
|
||||
if matches!(
|
||||
thread.thread_type,
|
||||
ThreadType::Mission | ThreadType::Reflection
|
||||
) {
|
||||
continue;
|
||||
// Only react to threads transitioning to Done
|
||||
let is_done = matches!(
|
||||
event.kind,
|
||||
crate::types::event::EventKind::StateChanged {
|
||||
to: crate::types::thread::ThreadState::Done,
|
||||
..
|
||||
}
|
||||
);
|
||||
if !is_done {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build trace to check for issues
|
||||
let trace = crate::executor::trace::build_trace(&thread);
|
||||
if trace.issues.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Load the completed thread
|
||||
let thread = match mgr.store.load_thread(event.thread_id).await {
|
||||
Ok(Some(t)) => t,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Build trigger payload with trace issues, error messages,
|
||||
// and reflection summary
|
||||
// Skip Mission threads (no recursive self-improvement)
|
||||
if thread.thread_type == ThreadType::Mission {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Trigger 1: Error diagnosis ──────────────────
|
||||
let trace = crate::executor::trace::build_trace(&thread);
|
||||
if !trace.issues.is_empty() {
|
||||
let issues: Vec<serde_json::Value> = trace
|
||||
.issues
|
||||
.iter()
|
||||
@@ -328,8 +336,6 @@ impl MissionManager {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract actual error text from ActionFailed events
|
||||
// and system messages (these contain the real diagnostics)
|
||||
let error_messages: Vec<String> = thread
|
||||
.events
|
||||
.iter()
|
||||
@@ -345,7 +351,7 @@ impl MissionManager {
|
||||
None
|
||||
}
|
||||
})
|
||||
.take(10) // cap to avoid bloating payload
|
||||
.take(10)
|
||||
.collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
@@ -353,10 +359,6 @@ impl MissionManager {
|
||||
"goal": thread.goal,
|
||||
"issues": issues,
|
||||
"error_messages": error_messages,
|
||||
"reflection": {
|
||||
"docs_produced": docs_produced,
|
||||
"doc_types": doc_types,
|
||||
},
|
||||
});
|
||||
|
||||
if let Err(e) = mgr
|
||||
@@ -368,7 +370,121 @@ impl MissionManager {
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("event listener: failed to fire self-improvement: {e}");
|
||||
warn!("event listener: failed to fire error diagnosis: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trigger 2: Playbook extraction ──────────────
|
||||
let action_count = thread
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e.kind,
|
||||
crate::types::event::EventKind::ActionExecuted { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
if thread.state == crate::types::thread::ThreadState::Done
|
||||
&& trace.issues.iter().all(|i| {
|
||||
i.severity != crate::executor::trace::IssueSeverity::Error
|
||||
})
|
||||
&& thread.step_count >= PLAYBOOK_MIN_STEPS
|
||||
&& action_count >= PLAYBOOK_MIN_ACTIONS
|
||||
{
|
||||
let actions_used: Vec<String> = thread
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let crate::types::event::EventKind::ActionExecuted {
|
||||
action_name,
|
||||
..
|
||||
} = &e.kind
|
||||
{
|
||||
Some(action_name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"source_thread_id": event.thread_id.0.to_string(),
|
||||
"goal": thread.goal,
|
||||
"step_count": thread.step_count,
|
||||
"action_count": action_count,
|
||||
"actions_used": actions_used,
|
||||
"total_tokens": thread.total_tokens_used,
|
||||
});
|
||||
|
||||
if let Err(e) = mgr
|
||||
.fire_on_system_event(
|
||||
"engine",
|
||||
"thread_completed_with_learnings",
|
||||
&user_id,
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("event listener: failed to fire playbook extraction: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trigger 3: Conversation insights ────────────
|
||||
// Use the thread's project_id as a proxy for conversation scope.
|
||||
let conv_key = thread.project_id.0.to_string();
|
||||
let count = conv_thread_counts
|
||||
.entry(conv_key.clone())
|
||||
.or_insert(0);
|
||||
*count += 1;
|
||||
|
||||
if (*count).is_multiple_of(CONVERSATION_INSIGHTS_INTERVAL) {
|
||||
// Collect recent thread goals for context
|
||||
let thread_goals: Vec<String> = match mgr
|
||||
.store
|
||||
.list_threads(thread.project_id)
|
||||
.await
|
||||
{
|
||||
Ok(threads) => threads
|
||||
.iter()
|
||||
.rev()
|
||||
.take(CONVERSATION_INSIGHTS_INTERVAL as usize)
|
||||
.map(|t| t.goal.clone())
|
||||
.collect(),
|
||||
Err(_) => vec![thread.goal.clone()],
|
||||
};
|
||||
|
||||
// Collect sample user messages from recent threads
|
||||
let sample_messages: Vec<String> = thread
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.role == crate::types::message::MessageRole::User
|
||||
})
|
||||
.map(|m| {
|
||||
m.content.chars().take(200).collect::<String>()
|
||||
})
|
||||
.take(10)
|
||||
.collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"project_id": thread.project_id.0.to_string(),
|
||||
"completed_thread_count": *count,
|
||||
"thread_goals": thread_goals,
|
||||
"sample_user_messages": sample_messages,
|
||||
});
|
||||
|
||||
if let Err(e) = mgr
|
||||
.fire_on_system_event(
|
||||
"engine",
|
||||
"conversation_insights_due",
|
||||
&user_id,
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("event listener: failed to fire conversation insights: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +562,88 @@ impl MissionManager {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Ensure all three learning missions exist for the given project.
|
||||
///
|
||||
/// Creates (if missing) the self-improvement, playbook extraction, and
|
||||
/// conversation insights missions. This is the preferred entry point —
|
||||
/// call once at project bootstrap.
|
||||
pub async fn ensure_learning_missions(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
) -> Result<(), EngineError> {
|
||||
// 1. Error diagnosis (self-improvement) — existing
|
||||
self.ensure_self_improvement_mission(project_id).await?;
|
||||
|
||||
// 2. Playbook extraction
|
||||
self.ensure_mission_by_metadata(
|
||||
project_id,
|
||||
"playbook_extraction",
|
||||
"playbook-extraction",
|
||||
PLAYBOOK_EXTRACTION_GOAL,
|
||||
MissionCadence::OnSystemEvent {
|
||||
source: "engine".into(),
|
||||
event_type: "thread_completed_with_learnings".into(),
|
||||
},
|
||||
"Extract reusable playbooks from successful multi-step threads",
|
||||
3, // max 3/day
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. Conversation insights
|
||||
self.ensure_mission_by_metadata(
|
||||
project_id,
|
||||
"conversation_insights",
|
||||
"conversation-insights",
|
||||
CONVERSATION_INSIGHTS_GOAL,
|
||||
MissionCadence::OnSystemEvent {
|
||||
source: "engine".into(),
|
||||
event_type: "conversation_insights_due".into(),
|
||||
},
|
||||
"Extract user preferences, domain knowledge, and workflow patterns from conversations",
|
||||
2, // max 2/day
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure a mission with a specific metadata tag exists, creating it if not.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ensure_mission_by_metadata(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
metadata_key: &str,
|
||||
name: &str,
|
||||
goal: &str,
|
||||
cadence: MissionCadence,
|
||||
success_criteria: &str,
|
||||
max_per_day: u32,
|
||||
) -> Result<MissionId, EngineError> {
|
||||
let missions = self.store.list_missions(project_id).await?;
|
||||
if let Some(existing) = missions
|
||||
.iter()
|
||||
.find(|m| m.metadata.get(metadata_key).is_some())
|
||||
{
|
||||
let mut active = self.active.write().await;
|
||||
if !active.contains(&existing.id) {
|
||||
active.push(existing.id);
|
||||
}
|
||||
return Ok(existing.id);
|
||||
}
|
||||
|
||||
let mut mission = Mission::new(project_id, name, goal, cadence);
|
||||
mission.success_criteria = Some(success_criteria.into());
|
||||
mission.metadata = serde_json::json!({metadata_key: true});
|
||||
mission.max_threads_per_day = max_per_day;
|
||||
|
||||
let id = mission.id;
|
||||
self.store.save_mission(&mission).await?;
|
||||
self.active.write().await.push(id);
|
||||
|
||||
debug!(mission_id = %id, name, "created learning mission");
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Tick — check all active missions and fire any that are due.
|
||||
///
|
||||
/// For `Cron` cadence missions, checks `next_fire_at` against current time.
|
||||
@@ -901,6 +1099,91 @@ pub const FIX_PATTERN_DB_TITLE: &str = "fix_pattern_database";
|
||||
/// Well-known tag for the fix pattern database.
|
||||
pub const FIX_PATTERN_DB_TAG: &str = "fix_patterns";
|
||||
|
||||
/// The goal for the playbook extraction mission.
|
||||
const PLAYBOOK_EXTRACTION_GOAL: &str = "\
|
||||
You extract reusable playbooks from successfully completed multi-step threads.
|
||||
|
||||
## Input
|
||||
|
||||
`state[\"trigger_payload\"]` contains:
|
||||
- `source_thread_id` — the thread that completed successfully
|
||||
- `goal` — what the thread accomplished
|
||||
- `step_count` — number of execution steps
|
||||
- `action_count` — number of tool actions executed
|
||||
- `actions_used` — list of tool names used
|
||||
- `total_tokens` — tokens consumed
|
||||
|
||||
## Process
|
||||
|
||||
1. Search for the source thread's messages in memory: `memory_search(query=goal)`
|
||||
2. Check for existing playbooks that cover this procedure: `memory_search(query=\"playbook\")`
|
||||
3. If a similar playbook already exists, decide whether this thread adds new detail worth updating
|
||||
4. Extract the step-by-step procedure, noting specific tool names and parameter patterns
|
||||
5. Save as a Playbook memory doc via `memory_write(target=\"memory\", content=playbook_text)` \
|
||||
with title format \"playbook:<short-name>\"
|
||||
|
||||
## Output (FINAL)
|
||||
|
||||
Report what you did:
|
||||
- The playbook title and a one-line summary
|
||||
- Whether it is new or an update to an existing playbook
|
||||
- Next focus: what patterns to watch for
|
||||
|
||||
## Rules
|
||||
|
||||
- Only extract playbooks from threads with 3+ distinct tool calls
|
||||
- Be specific about tool names and parameters — vague playbooks are useless
|
||||
- If the thread was a trivial query-response, call FINAL(\"No playbook needed — simple interaction\") \
|
||||
and stop immediately
|
||||
- One playbook per FINAL — do not combine unrelated procedures
|
||||
";
|
||||
|
||||
/// The goal for the conversation insights mission.
|
||||
const CONVERSATION_INSIGHTS_GOAL: &str = "\
|
||||
You extract user preferences, patterns, and domain knowledge from a batch of recent \
|
||||
conversation threads.
|
||||
|
||||
## Input
|
||||
|
||||
`state[\"trigger_payload\"]` contains:
|
||||
- `project_id` — the project scope
|
||||
- `completed_thread_count` — total threads completed in this conversation
|
||||
- `thread_goals` — list of recent thread goals (what the user asked for)
|
||||
- `sample_user_messages` — sample of actual user messages (truncated to 200 chars)
|
||||
|
||||
## Process
|
||||
|
||||
1. Analyze the thread goals and user messages for patterns
|
||||
2. Search existing insights: `memory_search(query=\"user preferences\")` and \
|
||||
`memory_search(query=\"domain knowledge\")`
|
||||
3. Extract NEW insights not already recorded in memory
|
||||
4. Write each insight to memory via `memory_write(target=\"memory\", content=insight_text)` \
|
||||
with title format \"insight:<category>:<topic>\"
|
||||
|
||||
## Categories to look for
|
||||
|
||||
- **Preferences**: communication style, format choices, tool preferences
|
||||
- **Domain**: project names, API patterns, data formats, technology stack
|
||||
- **Workflow**: recurring task sequences, common follow-up questions
|
||||
- **Corrections**: things the user corrected or repeated — these signal unmet expectations
|
||||
|
||||
## Output (FINAL)
|
||||
|
||||
Report:
|
||||
- Number of new insights extracted (0 is fine)
|
||||
- Brief list of what was found
|
||||
- Next focus
|
||||
|
||||
## Rules
|
||||
|
||||
- Only record actionable, specific insights — not vague observations
|
||||
- Do not record personal information, only work patterns
|
||||
- If no meaningful new insights after analysis, call FINAL(\"No new insights — \
|
||||
conversation patterns already captured\") immediately
|
||||
- Merge with existing insight docs rather than creating duplicates
|
||||
- Max 5 insights per run to keep quality high
|
||||
";
|
||||
|
||||
/// Seed content for the fix pattern database.
|
||||
const SEED_FIX_PATTERNS: &str = "\
|
||||
| Trace pattern | Fix strategy | Location pattern |
|
||||
|
||||
@@ -122,17 +122,6 @@ pub enum EventKind {
|
||||
approved: bool,
|
||||
},
|
||||
|
||||
// ── Reflection ───────────────────────────────────────────
|
||||
ReflectionStarted,
|
||||
ReflectionComplete {
|
||||
docs_produced: usize,
|
||||
doc_types: Vec<String>,
|
||||
tokens_used: u64,
|
||||
},
|
||||
ReflectionFailed {
|
||||
error: String,
|
||||
},
|
||||
|
||||
// ── Self-improvement ──────────────────────────────────────
|
||||
SelfImprovementStarted,
|
||||
SelfImprovementComplete {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::memory::DocId;
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// The origin of a piece of data.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -21,8 +20,6 @@ pub enum Provenance {
|
||||
ToolOutput { action_name: String },
|
||||
/// Generated by the LLM.
|
||||
LlmGenerated,
|
||||
/// Produced by the reflection pipeline.
|
||||
Reflection { source_thread_id: ThreadId },
|
||||
/// Retrieved from project memory.
|
||||
MemoryRetrieval { doc_id: DocId },
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Default for ThreadId {
|
||||
/// ```text
|
||||
/// Created → Running → Waiting → Running (resume)
|
||||
/// → Suspended → Running (resume)
|
||||
/// → Completed → Reflecting → Done
|
||||
/// → Completed → Done
|
||||
/// → Failed
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -51,10 +51,8 @@ pub enum ThreadState {
|
||||
Waiting,
|
||||
/// Paused by system (resource pressure, priority preemption).
|
||||
Suspended,
|
||||
/// Execution finished successfully, may undergo reflection.
|
||||
/// Execution finished successfully.
|
||||
Completed,
|
||||
/// Post-completion reflection is running.
|
||||
Reflecting,
|
||||
/// Fully finished (terminal).
|
||||
Done,
|
||||
/// Terminal failure.
|
||||
@@ -81,11 +79,7 @@ impl ThreadState {
|
||||
| (Self::Suspended, Self::Running)
|
||||
| (Self::Suspended, Self::Failed)
|
||||
// From Completed
|
||||
| (Self::Completed, Self::Reflecting)
|
||||
| (Self::Completed, Self::Done)
|
||||
// From Reflecting
|
||||
| (Self::Reflecting, Self::Done)
|
||||
| (Self::Reflecting, Self::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,8 +105,6 @@ pub enum ThreadType {
|
||||
Research,
|
||||
/// Long-running goal that spawns threads over time.
|
||||
Mission,
|
||||
/// Post-completion analysis of another thread.
|
||||
Reflection,
|
||||
}
|
||||
|
||||
// ── Thread configuration ────────────────────────────────────
|
||||
@@ -124,8 +116,6 @@ pub struct ThreadConfig {
|
||||
pub max_iterations: usize,
|
||||
/// Maximum wall-clock duration for the thread.
|
||||
pub max_duration: Option<std::time::Duration>,
|
||||
/// Whether to run reflection after completion.
|
||||
pub enable_reflection: bool,
|
||||
/// Whether to detect and nudge on tool intent without action calls.
|
||||
pub enable_tool_intent_nudge: bool,
|
||||
/// Maximum number of tool intent nudges per thread.
|
||||
@@ -160,7 +150,6 @@ impl Default for ThreadConfig {
|
||||
Self {
|
||||
max_iterations: 50,
|
||||
max_duration: None,
|
||||
enable_reflection: false,
|
||||
enable_tool_intent_nudge: true,
|
||||
max_tool_intent_nudges: 2,
|
||||
max_tokens_total: None,
|
||||
@@ -350,21 +339,11 @@ mod tests {
|
||||
assert!(ThreadState::Suspended.can_transition_to(ThreadState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_can_transition_to_reflecting() {
|
||||
assert!(ThreadState::Completed.can_transition_to(ThreadState::Reflecting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_can_transition_to_done() {
|
||||
assert!(ThreadState::Completed.can_transition_to(ThreadState::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflecting_can_transition_to_done() {
|
||||
assert!(ThreadState::Reflecting.can_transition_to(ThreadState::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_is_terminal() {
|
||||
assert!(ThreadState::Done.is_terminal());
|
||||
@@ -430,16 +409,6 @@ mod tests {
|
||||
assert!(t.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_lifecycle_with_reflection() {
|
||||
let mut t = make_thread();
|
||||
t.transition_to(ThreadState::Running, None).unwrap();
|
||||
t.transition_to(ThreadState::Completed, None).unwrap();
|
||||
t.transition_to(ThreadState::Reflecting, None).unwrap();
|
||||
t.transition_to(ThreadState::Done, None).unwrap();
|
||||
assert_eq!(t.events.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_records_event() {
|
||||
let mut t = make_thread();
|
||||
|
||||
+4
-35
@@ -201,12 +201,12 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> {
|
||||
mission_manager.start_cron_ticker(agent.deps.owner_id.clone());
|
||||
mission_manager.start_event_listener(agent.deps.owner_id.clone());
|
||||
|
||||
// Ensure self-improvement mission exists for this project
|
||||
// Ensure all learning missions exist for this project
|
||||
if let Err(e) = mission_manager
|
||||
.ensure_self_improvement_mission(project_id)
|
||||
.ensure_learning_missions(project_id)
|
||||
.await
|
||||
{
|
||||
debug!("engine v2: failed to create self-improvement mission: {e}");
|
||||
debug!("engine v2: failed to create learning missions: {e}");
|
||||
}
|
||||
|
||||
// Wire mission manager into effect adapter for mission_* function calls
|
||||
@@ -816,10 +816,7 @@ pub async fn handle_with_engine(
|
||||
content,
|
||||
state.default_project_id,
|
||||
&message.user_id,
|
||||
ThreadConfig {
|
||||
enable_reflection: true,
|
||||
..ThreadConfig::default()
|
||||
},
|
||||
ThreadConfig::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| engine_err("thread error", e))?;
|
||||
@@ -1108,26 +1105,6 @@ async fn forward_event_to_channel(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventKind::ReflectionStarted => {
|
||||
let _ = channels
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::Thinking("Reflecting on execution...".into()),
|
||||
metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventKind::ReflectionComplete { docs_produced, .. } => {
|
||||
let _ = channels
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::Thinking(format!(
|
||||
"Reflection complete — {docs_produced} insight(s) saved"
|
||||
)),
|
||||
metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1211,14 +1188,6 @@ fn thread_event_to_app_events(
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
EventKind::ReflectionStarted => vec![AppEvent::Thinking {
|
||||
message: "Reflecting on execution...".into(),
|
||||
thread_id: Some(thread_id.into()),
|
||||
}],
|
||||
EventKind::ReflectionComplete { docs_produced, .. } => vec![AppEvent::Status {
|
||||
message: format!("Reflection complete — {docs_produced} insight(s) saved"),
|
||||
thread_id: Some(thread_id.into()),
|
||||
}],
|
||||
EventKind::StateChanged { from, to, reason } => {
|
||||
vec![AppEvent::ThreadStateChanged {
|
||||
thread_id: thread_id.into(),
|
||||
|
||||
+8
-13
@@ -534,22 +534,17 @@ impl Channel for GatewayChannel {
|
||||
user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let thread_id = match response.thread_id {
|
||||
Some(tid) => tid,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Gateway broadcast with no thread_id — skipping (clients would drop it)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.state.sse.broadcast_for_user(
|
||||
user_id,
|
||||
AppEvent::Response {
|
||||
let event = match response.thread_id {
|
||||
Some(thread_id) => AppEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
},
|
||||
);
|
||||
None => AppEvent::Status {
|
||||
message: response.content,
|
||||
thread_id: None,
|
||||
},
|
||||
};
|
||||
self.state.sse.broadcast_for_user(user_id, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user