feat(engine): platform self-awareness, event pipeline fix, globals() builtin, prompt templates

Session 9 changes driven by live trace analysis:

- CodeAct event pipeline: handle_execute_code_step now transfers
  CodeExecutionResult events to thread.events and broadcasts via event_tx
  (fixes false-positive no_tools_used trace warnings)
- Monty globals()/locals() builtins: returns dict of available action names
  from capability leases, enabling "tool_name" in globals() probing
- PlatformInfo injection into system prompts (version, LLM backend, model,
  database, channels, owner, repo URL)
- Mission goal prompts moved to prompts/*.md files (include_str! pattern)
- /expected command for triggering self-improvement from user feedback
- Session 9 development history

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-27 22:39:59 -07:00
co-authored by Claude Opus 4.6
parent ae0cae3a22
commit 85bcaa64e9
18 changed files with 645 additions and 207 deletions
+1
View File
@@ -144,6 +144,7 @@ All commands parsed by `SubmissionParser::parse()`:
| `/heartbeat` | `Heartbeat` | |
| `/summarize`, `/summary` | `Summarize` | |
| `/suggest` | `Suggest` | |
| `/expected <desc>` | `Expected` | Fires self-improvement with conversation context |
| `/new`, `/thread new` | `NewThread` | |
| `/thread <uuid>` | `SwitchThread` | Must be valid UUID |
| `/resume <uuid>` | `Resume` | Must be valid UUID |
+37 -1
View File
@@ -203,6 +203,9 @@ pub struct Agent {
/// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
/// Engine v2 mission manager for firing learning missions (set after engine init).
pub(crate) mission_manager_slot:
Arc<tokio::sync::RwLock<Option<Arc<ironclaw_engine::MissionManager>>>>,
}
impl Agent {
@@ -274,6 +277,7 @@ impl Agent {
hygiene_config,
routine_config,
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
mission_manager_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
@@ -286,10 +290,21 @@ impl Agent {
self.routine_engine_slot = slot;
}
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
pub(super) async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
self.routine_engine_slot.read().await.clone()
}
/// Set the engine v2 mission manager (called after engine init).
pub async fn set_mission_manager(&self, mgr: Arc<ironclaw_engine::MissionManager>) {
*self.mission_manager_slot.write().await = Some(mgr);
}
pub(crate) async fn mission_manager(
&self,
) -> Option<Arc<ironclaw_engine::MissionManager>> {
self.mission_manager_slot.read().await.clone()
}
// Convenience accessors
/// Get the scheduler (for external wiring, e.g. CreateJobTool).
@@ -326,6 +341,23 @@ impl Agent {
&self.deps.hooks
}
/// Build platform metadata for self-awareness in system prompts.
pub(crate) async fn platform_info(&self) -> ironclaw_engine::PlatformInfo {
let active_channels = self.channels.channel_names().await;
let database_backend = std::env::var("DATABASE_BACKEND")
.ok()
.or_else(|| self.deps.store.as_ref().map(|_| "postgres".to_string()));
ironclaw_engine::PlatformInfo {
version: Some(env!("CARGO_PKG_VERSION").to_string()),
llm_backend: Some(self.deps.llm_backend.clone()),
model_name: Some(self.deps.llm.active_model_name()),
database_backend,
active_channels,
owner_id: Some(self.deps.owner_id.clone()),
repo_url: Some("https://github.com/nearai/ironclaw".to_string()),
}
}
pub(super) fn cost_guard(&self) -> &Arc<crate::agent::cost_guard::CostGuard> {
&self.deps.cost_guard
}
@@ -1456,6 +1488,10 @@ impl Agent {
Submission::Heartbeat => self.process_heartbeat().await,
Submission::Summarize => self.process_summarize(session, thread_id).await,
Submission::Suggest => self.process_suggest(session, thread_id).await,
Submission::Expected { description } => {
self.process_expected(session, thread_id, &description, &message.user_id)
.await
}
Submission::JobStatus { job_id } => {
self.process_job_status(&tenant, job_id.as_deref()).await
}
+103
View File
@@ -472,6 +472,109 @@ impl Agent {
}
}
/// Handle `/expected <description>` — capture expected behavior and fire into
/// the self-improvement pipeline.
///
/// Collects recent conversation turns (user input, tool calls, responses) and
/// packages them with the user's description of what should have happened.
/// This fires a `user_feedback:expected_behavior` system event that the
/// expected-behavior learning mission picks up.
pub(super) async fn process_expected(
&self,
session: Arc<Mutex<Session>>,
thread_id: Uuid,
description: &str,
user_id: &str,
) -> Result<SubmissionResult, Error> {
// Extract recent turns from the session (last 5 turns for context)
let recent_context = {
let sess = session.lock().await;
let thread = sess
.threads
.get(&thread_id)
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
let turns: Vec<serde_json::Value> = thread
.turns
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|turn| {
let tool_calls: Vec<serde_json::Value> = turn
.tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"tool": tc.name,
"error": tc.error,
})
})
.collect();
serde_json::json!({
"user_input": turn.user_input,
"response": turn.response,
"tool_calls": tool_calls,
"state": format!("{:?}", turn.state),
"error": turn.error,
})
})
.collect();
turns
};
if recent_context.is_empty() {
return Ok(SubmissionResult::ok_with_message(
"No conversation history to attach feedback to.",
));
}
let payload = serde_json::json!({
"expected_behavior": description,
"thread_id": thread_id.to_string(),
"recent_turns": recent_context,
});
// Fire into v2 mission manager (learning missions)
let mut fired: usize = 0;
if let Some(mgr) = self.mission_manager().await {
match mgr
.fire_on_system_event(
"user_feedback",
"expected_behavior",
user_id,
Some(payload.clone()),
)
.await
{
Ok(ids) => fired += ids.len(),
Err(e) => {
tracing::debug!("failed to fire expected-behavior mission: {e}");
}
}
}
// Also fire through v1 routine engine (if routines listen for this)
if let Some(engine) = self.routine_engine().await {
fired += engine
.emit_system_event("user_feedback", "expected_behavior", &payload, Some(user_id))
.await;
}
if fired > 0 {
Ok(SubmissionResult::ok_with_message(format!(
"Feedback captured. Fired {fired} self-improvement thread(s) to investigate."
)))
} else {
Ok(SubmissionResult::ok_with_message(
"Feedback noted but no self-improvement missions are configured to handle it. \
The engine will use this context in future learning cycles.",
))
}
}
/// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
pub(super) async fn handle_reasoning_command(
&self,
+2 -1
View File
@@ -127,7 +127,8 @@ impl Agent {
let mut reasoning = Reasoning::new(self.llm().clone())
.with_channel(message.channel.clone())
.with_model_name(self.llm().active_model_name())
.with_group_chat(is_group_chat);
.with_group_chat(is_group_chat)
.with_platform_info(self.platform_info().await);
// Pass channel-specific conversation context to the LLM.
// This helps the agent know who/group it's talking to.
+29
View File
@@ -41,6 +41,12 @@ impl SubmissionParser {
if lower == "/suggest" {
return Submission::Suggest;
}
if lower.starts_with("/expected ") {
let description = trimmed["/expected ".len()..].trim().to_string();
if !description.is_empty() {
return Submission::Expected { description };
}
}
if lower == "/thread new" || lower == "/new" {
return Submission::NewThread;
}
@@ -271,6 +277,13 @@ pub enum Submission {
/// Suggest next steps based on the current thread.
Suggest,
/// User-provided expected behavior for the last interaction.
/// Fires into the self-improvement pipeline with conversation context.
Expected {
/// What the user expected to happen.
description: String,
},
/// Check job status. No job_id shows all jobs; with job_id shows a specific job.
JobStatus {
/// Optional job ID (UUID or short prefix). If None, shows all jobs.
@@ -867,4 +880,20 @@ mod tests {
assert!(matches!(SubmissionParser::parse("/QUIT"), Submission::Quit));
assert!(matches!(SubmissionParser::parse("/Exit"), Submission::Quit));
}
#[test]
fn test_parser_expected() {
let submission =
SubmissionParser::parse("/expected should have logged in via GitHub OAuth");
assert!(
matches!(submission, Submission::Expected { description } if description == "should have logged in via GitHub OAuth")
);
}
#[test]
fn test_parser_expected_empty_is_user_input() {
// "/expected " with no description should fall through to user input
let submission = SubmissionParser::parse("/expected ");
assert!(matches!(submission, Submission::UserInput { .. }));
}
}