From 85bcaa64e988735fbb4a32045283ffa96e49fa3b Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Fri, 27 Mar 2026 22:39:34 -0700 Subject: [PATCH] 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) --- CLAUDE.md | 1 + .../prompts/mission_conversation_insights.md | 38 +++ .../prompts/mission_expected_behavior.md | 58 +++++ .../prompts/mission_self_improvement.md | 67 ++++++ .../prompts/mission_skill_extraction.md | 69 ++++++ .../src/executor/loop_engine.rs | 10 + .../src/executor/orchestrator.rs | 22 +- crates/ironclaw_engine/src/executor/prompt.rs | 107 ++++++++- .../ironclaw_engine/src/executor/scripting.rs | 22 ++ crates/ironclaw_engine/src/lib.rs | 1 + crates/ironclaw_engine/src/runtime/mission.rs | 218 ++---------------- docs/development-history.md | 38 +++ src/agent/CLAUDE.md | 1 + src/agent/agent_loop.rs | 38 ++- src/agent/commands.rs | 103 +++++++++ src/agent/dispatcher.rs | 3 +- src/agent/submission.rs | 29 +++ src/llm/reasoning.rs | 27 ++- 18 files changed, 645 insertions(+), 207 deletions(-) create mode 100644 crates/ironclaw_engine/prompts/mission_conversation_insights.md create mode 100644 crates/ironclaw_engine/prompts/mission_expected_behavior.md create mode 100644 crates/ironclaw_engine/prompts/mission_self_improvement.md create mode 100644 crates/ironclaw_engine/prompts/mission_skill_extraction.md diff --git a/CLAUDE.md b/CLAUDE.md index ad8b5553..58922883 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ E2E tests: see `tests/e2e/CLAUDE.md`. - Prefer strong types over strings (enums, newtypes) - Keep functions focused, extract helpers when logic is reused - Comments for non-obvious logic only +- **Prompt templates live in files, not Rust code**: Multi-line prompt strings (mission goals, system prompts, CodeAct preambles) go in `crates/ironclaw_engine/prompts/*.md` and are loaded via `include_str!()`. Never inline large prompt templates as Rust string constants — they're hard to read, review, and iterate on. Single-line format strings are fine inline. - **Logging levels matter for REPL/TUI**: `info!` and `warn!` output appears in the REPL and corrupts the terminal UI. Use `debug!` for internal diagnostics (trace analysis, reflection results, engine internals). Reserve `info!` for user-facing status that the REPL intentionally renders. Background tasks (reflection, trace analysis) must NEVER use `info!` — it breaks the interactive display. ## Architecture diff --git a/crates/ironclaw_engine/prompts/mission_conversation_insights.md b/crates/ironclaw_engine/prompts/mission_conversation_insights.md new file mode 100644 index 00000000..ee5018f9 --- /dev/null +++ b/crates/ironclaw_engine/prompts/mission_conversation_insights.md @@ -0,0 +1,38 @@ +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::" + +## 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 diff --git a/crates/ironclaw_engine/prompts/mission_expected_behavior.md b/crates/ironclaw_engine/prompts/mission_expected_behavior.md new file mode 100644 index 00000000..875b52dc --- /dev/null +++ b/crates/ironclaw_engine/prompts/mission_expected_behavior.md @@ -0,0 +1,58 @@ +You investigate why IronClaw did not behave as the user expected. The user used the `/expected` command to describe what should have happened, and the trigger payload includes the recent conversation turns showing what actually happened. + +## Input + +`state["trigger_payload"]` contains: +- `expected_behavior` — what the user expected to happen (their description) +- `thread_id` — the conversation thread where the issue occurred +- `recent_turns` — list of recent turns, each with: + - `user_input` — what the user asked + - `response` — what the agent responded + - `tool_calls` — list of tools called (with name and any errors) + - `state` — turn completion state + - `error` — any error message + +## Investigation process + +1. **Understand the gap**: Compare `expected_behavior` against `recent_turns`. What did the user want? What actually happened? Be precise about the delta. + +2. **Classify the root cause**: + - MISSING_CAPABILITY: The agent doesn't have the tool or integration needed (e.g. no GitHub OAuth, no API key configured) + - WRONG_TOOL_CHOICE: The agent had the right tools but chose the wrong one or didn't use them at all + - PROMPT_GAP: The agent didn't know the right approach because the system prompt lacks guidance for this scenario + - CONFIG_ISSUE: A timeout, limit, or default prevented success + - BUG: Actual code error in tool execution or response processing + +3. **Apply a fix** based on classification: + + MISSING_CAPABILITY: + - Search for relevant skills: `skill_search(query="...")` or `tool_search(query="...")` + - If a skill/tool exists but isn't installed, note it as a recommendation + - If nothing exists, add a prompt rule acknowledging the limitation and suggesting alternatives the user can take + + WRONG_TOOL_CHOICE or PROMPT_GAP: + - Apply a Level 1 (prompt overlay) fix — add a rule that guides the agent in this scenario + - Use `memory_write` with title="prompt:codeact_preamble" and tags=["prompt_overlay"] + - The rule must be specific and actionable + + CONFIG_ISSUE: + - Diagnose via `read_file` and `shell` commands + - Apply Level 2 fix if safe (branch, change, test, commit) + + BUG: + - Read relevant source files to understand the issue + - Propose a Level 3 fix (describe but don't apply) + +4. **Record** in FINAL(): + - What the user expected vs what happened (one sentence each) + - Root cause classification + - What fix was applied (or recommended) + - Next focus + +## Rules + +- The user's expectation is the ground truth — don't argue with it +- If multiple issues exist, fix the most impactful one first +- Be specific in prompt rules ("When asked to file a GitHub issue, use the http tool with the GitHub API" is good; "Try harder" is useless) +- If the gap is a missing credential or integration, say so clearly — don't pretend the capability exists +- Max one fix per run diff --git a/crates/ironclaw_engine/prompts/mission_self_improvement.md b/crates/ironclaw_engine/prompts/mission_self_improvement.md new file mode 100644 index 00000000..e3d02dfc --- /dev/null +++ b/crates/ironclaw_engine/prompts/mission_self_improvement.md @@ -0,0 +1,67 @@ +You are a self-improvement agent for the IronClaw engine. You receive trigger payloads containing execution trace issues from completed threads. Your job is to diagnose root causes and apply fixes so the same issue doesn't recur. + +## What you have access to + +- `state["trigger_payload"]` — JSON with `issues` (list of {severity, category, description, step}), `error_messages` (actual error text from failed actions), `goal` (what the thread was trying to do), and `source_thread_id`. +- All tools: shell, read_file, write_file, apply_patch, web_search, memory_write, etc. +- The codebase at the current working directory. +- The fix pattern database in prior knowledge (if loaded). + +## The experiment loop + +For each issue in the trigger payload: + +1. **Diagnose**: Read the error messages and issue descriptions. Classify the root cause: + - PROMPT: The LLM made a mistake because the system prompt is missing a rule (wrong tool name, bad API usage, ignoring tool results) + - CONFIG: A default value is wrong (truncation length, iteration limit, timeout) + - CODE: There is a bug in the engine or bridge code (crash, type error, missing conversion) + +2. **Check the fix pattern database** in prior knowledge. Has this pattern been seen before? If yes, apply the known strategy. If no, proceed to step 3. + +3. **Apply the fix** based on the level: + + Level 1 (PROMPT — low risk, apply directly): + - Read the current prompt overlay: `memory_search("prompt:codeact_preamble")` + - Write an updated overlay with a new rule appended + - Use `memory_write` with title="prompt:codeact_preamble" and tags=["prompt_overlay"] + - The rule should be specific and actionable (e.g. "Never call web_fetch — use http() instead") + + Level 2 (CONFIG — medium risk): + - Use `read_file` to find the relevant constant or default + - Use `shell` to create a git branch: `git checkout -b self-improve/issue-description` + - Apply the change with `apply_patch` or `write_file` + - Run tests: `cargo test -p ironclaw_engine` + - If tests pass, commit. If not, revert: `git checkout main` + + Level 3 (CODE — high risk, just propose): + - Read the relevant source files + - Describe the fix needed but DO NOT apply it directly + - Log it as a recommendation in your FINAL() response + +4. **Record what you did** — include in your FINAL() response: + - What issue you analyzed + - What level fix you applied (1/2/3) + - What specific change you made + - Next focus: what to look for next time + +## Important rules + +- Be specific. "Never call web_fetch" is good. "Be careful with tool names" is useless. +- One fix per issue. Don't try to fix everything at once. +- For Level 1 fixes, the rule must be one sentence that can be appended to the prompt. +- If the trigger payload has no actionable issues (only Info severity), skip and call FINAL() immediately. +- NEVER modify test files to make a fix pass. +- NEVER modify security-sensitive code (safety layer, policy engine, leak detection). +- If you can't diagnose the root cause after reading the errors, log it and move on. + +## Level 1.5: Orchestrator patches (medium risk, auto-rollback) + +The execution loop itself is Python code that you can modify. This is the orchestrator — it handles tool dispatch, output formatting, state management, and context building. If the bug is in the glue between the LLM and tools (wrong output format, bad truncation, missing state), you can patch it directly. + +To modify the orchestrator: +1. Read current version: `memory_search("orchestrator:main")` +2. Make your change (keep it minimal — one fix at a time) +3. Save the new version: `memory_write` with title="orchestrator:main", tags=["orchestrator_code"], metadata={"version": N+1, "parent_version": N} +4. The next thread will use your updated orchestrator + +If your change causes 3 consecutive failures, the system auto-rolls back to the previous version. So be conservative — test your logic mentally before saving. diff --git a/crates/ironclaw_engine/prompts/mission_skill_extraction.md b/crates/ironclaw_engine/prompts/mission_skill_extraction.md new file mode 100644 index 00000000..a2a30335 --- /dev/null +++ b/crates/ironclaw_engine/prompts/mission_skill_extraction.md @@ -0,0 +1,69 @@ +You extract reusable skills 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 + +## Output Format + +Save as a Skill memory doc via `memory_write(target="memory", content=skill_prompt)` with: +- title: `"skill:"` (e.g., "skill:github-issue-triage") +- doc_type: `"skill"` +- metadata JSON: + ```json + { + "name": "", + "version": 1, + "description": "", + "activation": { + "keywords": ["", ""], + "patterns": [""], + "tags": [""], + "exclude_keywords": [], + "max_context_tokens": + }, + "source": "extracted", + "trust": "trusted", + "code_snippets": [ + { + "name": "", + "code": "def (...):\n ...", + "description": "" + } + ], + "metrics": {"usage_count": 0, "success_count": 0, "failure_count": 0}, + "content_hash": "" + } + ``` + +## Process + +1. Search for the source thread's context: `memory_search(query=goal)` +2. Check for existing skills: `memory_search(query="skill:")` +3. If a similar skill exists, update it (increment version) rather than creating a duplicate +4. Extract: + - Activation keywords from the goal + user messages (be specific, not generic) + - Step-by-step instructions as the prompt content + - Python code snippets for CodeAct (reusable functions using exact tool names) + - Domain tags (e.g., "github", "api", "data") + +## Output (FINAL) + +Report what you did: +- The skill title and a one-line summary +- Whether it is new or an update to an existing skill +- Next focus: what patterns to watch for + +## Rules + +- Only extract skills from threads with 3+ distinct tool calls +- Keywords must be specific (not generic words like "help", "do", "make") +- Code snippets must use exact tool function names as they appear in the thread +- If the thread was a trivial query-response, call FINAL("No skill needed — simple interaction") and stop immediately +- One skill per FINAL — do not combine unrelated procedures diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index e49f1fd4..84db6f6e 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -50,6 +50,8 @@ pub struct ExecutionLoop { retrieval: Option, /// Optional Store for runtime prompt overlay loading and skill retrieval. store: Option>, + /// Runtime platform metadata for self-awareness in system prompts. + platform_info: Option, } impl ExecutionLoop { @@ -74,6 +76,7 @@ impl ExecutionLoop { event_tx: None, retrieval: None, store: None, + platform_info: None, } } @@ -107,6 +110,12 @@ impl ExecutionLoop { self } + /// Set platform metadata for self-awareness in system prompts. + pub fn with_platform_info(mut self, info: crate::executor::prompt::PlatformInfo) -> Self { + self.platform_info = Some(info); + self + } + /// Add an event to the thread and broadcast it for live status updates. #[allow(dead_code)] fn emit_event(&mut self, kind: EventKind) { @@ -232,6 +241,7 @@ impl ExecutionLoop { &actions, self.store.as_ref(), self.thread.project_id, + self.platform_info.as_ref(), ) .await; diff --git a/crates/ironclaw_engine/src/executor/orchestrator.rs b/crates/ironclaw_engine/src/executor/orchestrator.rs index 9913c2c6..e2ddeb82 100644 --- a/crates/ironclaw_engine/src/executor/orchestrator.rs +++ b/crates/ironclaw_engine/src/executor/orchestrator.rs @@ -334,8 +334,10 @@ pub async fn execute_orchestrator( // __execute_code_step__(code, state) "__execute_code_step__" => { - handle_execute_code_step(args, kwargs, thread, llm, effects, leases, policy) - .await + handle_execute_code_step( + args, kwargs, thread, llm, effects, leases, policy, event_tx, + ) + .await } // __execute_action__(name, params, call_id=...) @@ -539,14 +541,16 @@ async fn handle_llm_complete( /// /// Runs user CodeAct code in a nested Monty VM with full tool dispatch. /// Returns a dict with stdout, return_value, action_results, etc. +#[allow(clippy::too_many_arguments)] async fn handle_execute_code_step( args: &[MontyObject], _kwargs: &[(MontyObject, MontyObject)], - thread: &Thread, + thread: &mut Thread, llm: &Arc, effects: &Arc, leases: &Arc, policy: &Arc, + event_tx: Option<&tokio::sync::broadcast::Sender>, ) -> ExtFunctionResult { let code = match args.first() { Some(obj) => monty_to_string(obj), @@ -586,6 +590,18 @@ async fn handle_execute_code_step( .await { Ok(result) => { + // Broadcast events from code execution to the thread and event channel. + // Without this, ActionExecuted events from CodeAct tool calls are lost + // and never appear in traces. + for event_kind in &result.events { + let event = ThreadEvent::new(thread.id, event_kind.clone()); + if let Some(tx) = event_tx { + let _ = tx.send(event.clone()); + } + thread.events.push(event); + } + thread.updated_at = chrono::Utc::now(); + let action_results: Vec = result .action_results .iter() diff --git a/crates/ironclaw_engine/src/executor/prompt.rs b/crates/ironclaw_engine/src/executor/prompt.rs index 0932554a..b71d8a73 100644 --- a/crates/ironclaw_engine/src/executor/prompt.rs +++ b/crates/ironclaw_engine/src/executor/prompt.rs @@ -14,6 +14,64 @@ use crate::traits::store::Store; use crate::types::capability::ActionDef; use crate::types::project::ProjectId; +/// Runtime platform metadata injected into system prompts for self-awareness. +/// +/// Provides the agent with knowledge about its own identity and environment +/// so it can answer questions about itself, its capabilities, and its +/// configuration without relying on training data. +#[derive(Debug, Clone, Default)] +pub struct PlatformInfo { + /// Software version (from CARGO_PKG_VERSION). + pub version: Option, + /// LLM backend name (e.g. "nearai", "openai", "anthropic"). + pub llm_backend: Option, + /// Active model name. + pub model_name: Option, + /// Database backend (e.g. "libsql", "postgres"). + pub database_backend: Option, + /// Active channel names (e.g. ["telegram", "cli"]). + pub active_channels: Vec, + /// Owner identifier. + pub owner_id: Option, + /// Project repository URL. + pub repo_url: Option, +} + +impl PlatformInfo { + /// Format as a prompt section. Returns empty string if no info is set. + pub fn to_prompt_section(&self) -> String { + let mut lines = Vec::new(); + + lines.push("You are **IronClaw**, a secure autonomous AI assistant platform.".into()); + if let Some(ref v) = self.version { + lines.push(format!("- Version: {v}")); + } + if let Some(ref repo) = self.repo_url { + lines.push(format!("- Repository: {repo}")); + } + if let Some(ref owner) = self.owner_id { + lines.push(format!("- Owner: {owner}")); + } + if let Some(ref backend) = self.llm_backend { + let model = self.model_name.as_deref().unwrap_or("default"); + lines.push(format!("- LLM: {backend} ({model})")); + } + if let Some(ref db) = self.database_backend { + lines.push(format!("- Database: {db}")); + } + if !self.active_channels.is_empty() { + lines.push(format!("- Channels: {}", self.active_channels.join(", "))); + } + + if lines.len() <= 1 { + // Only the identity line, no runtime details — still include it + return format!("\n\n## Platform\n\n{}\n", lines[0]); + } + + format!("\n\n## Platform\n\n{}\n", lines.join("\n")) + } +} + /// The main instruction block (before tool listing). const CODEACT_PREAMBLE: &str = include_str!("../../prompts/codeact_preamble.md"); @@ -46,9 +104,15 @@ pub async fn build_codeact_system_prompt( actions: &[ActionDef], store: Option<&Arc>, project_id: ProjectId, + platform: Option<&PlatformInfo>, ) -> String { let mut prompt = String::from(CODEACT_PREAMBLE); + // Inject platform identity and runtime metadata + if let Some(info) = platform { + prompt.push_str(&info.to_prompt_section()); + } + // Append runtime prompt overlay if available if let Some(store) = store && let Some(overlay) = load_prompt_overlay(store, project_id).await @@ -102,7 +166,7 @@ mod tests { #[tokio::test] async fn prompt_without_store_uses_compiled_preamble() { - let prompt = build_codeact_system_prompt(&[], None, ProjectId(uuid::Uuid::nil())).await; + let prompt = build_codeact_system_prompt(&[], None, ProjectId(uuid::Uuid::nil()), None).await; assert!(prompt.contains("Python REPL environment")); assert!(prompt.contains("Strategy")); assert!(!prompt.contains("Learned Rules")); @@ -126,7 +190,7 @@ mod tests { let store = Arc::new(crate::tests::InMemoryStore::with_docs(vec![overlay])); let prompt = - build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id).await; + build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id, None).await; assert!(prompt.contains("Learned Rules")); assert!(prompt.contains("Never call web_fetch")); } @@ -152,7 +216,7 @@ mod tests { let store = Arc::new(crate::tests::InMemoryStore::with_docs(vec![overlay])); let prompt = - build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id).await; + build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id, None).await; let snowman_count = prompt.chars().filter(|c| *c == '\u{2603}').count(); assert_eq!(snowman_count, MAX_PROMPT_OVERLAY_CHARS); @@ -177,8 +241,43 @@ mod tests { let store = Arc::new(crate::tests::InMemoryStore::with_docs(vec![overlay])); let prompt = - build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id).await; + build_codeact_system_prompt(&[], Some(&(store as Arc)), project_id, None).await; assert!(!prompt.contains("Should not appear")); assert!(!prompt.contains("Learned Rules")); } + + #[tokio::test] + async fn prompt_with_platform_info_injects_identity() { + let info = PlatformInfo { + version: Some("1.2.3".into()), + llm_backend: Some("nearai".into()), + model_name: Some("qwen3-235b".into()), + database_backend: Some("libsql".into()), + active_channels: vec!["telegram".into(), "cli".into()], + owner_id: Some("alice.near".into()), + repo_url: Some("https://github.com/nearai/ironclaw".into()), + }; + let prompt = build_codeact_system_prompt( + &[], + None, + ProjectId(uuid::Uuid::nil()), + Some(&info), + ) + .await; + assert!(prompt.contains("IronClaw")); + assert!(prompt.contains("1.2.3")); + assert!(prompt.contains("nearai")); + assert!(prompt.contains("qwen3-235b")); + assert!(prompt.contains("libsql")); + assert!(prompt.contains("telegram")); + assert!(prompt.contains("alice.near")); + assert!(prompt.contains("github.com/nearai/ironclaw")); + } + + #[tokio::test] + async fn prompt_without_platform_info_has_no_platform_section() { + let prompt = + build_codeact_system_prompt(&[], None, ProjectId(uuid::Uuid::nil()), None).await; + assert!(!prompt.contains("## Platform")); + } } diff --git a/crates/ironclaw_engine/src/executor/scripting.rs b/crates/ironclaw_engine/src/executor/scripting.rs index 5e33a895..c6dc8501 100644 --- a/crates/ironclaw_engine/src/executor/scripting.rs +++ b/crates/ironclaw_engine/src/executor/scripting.rs @@ -437,6 +437,21 @@ pub async fn execute_code_with_skills( .await } + // globals() / locals() — return dict with known names so + // `"tool_name" in globals()` works for capability probing + "globals" | "locals" => { + let entries: Vec<(MontyObject, MontyObject)> = known_actions + .iter() + .map(|name| { + ( + MontyObject::String(name.clone()), + MontyObject::Bool(true), + ) + }) + .collect(); + ExtFunctionResult::Return(MontyObject::Dict(entries.into())) + } + // Regular tool dispatch _ => { let dispatch = dispatch_action( @@ -518,6 +533,13 @@ pub async fn execute_code_with_skills( name: name.clone(), docstring: None, }) + } else if name == "globals" || name == "locals" { + // Python builtins for namespace introspection — resolve as + // callable so code like `"tool" in globals()` works. + NameLookupResult::Value(MontyObject::Function { + name: name.clone(), + docstring: None, + }) } else { debug!(name = %name, "Monty: unresolved name"); NameLookupResult::Undefined diff --git a/crates/ironclaw_engine/src/lib.rs b/crates/ironclaw_engine/src/lib.rs index 1c17d281..0d192094 100644 --- a/crates/ironclaw_engine/src/lib.rs +++ b/crates/ironclaw_engine/src/lib.rs @@ -55,6 +55,7 @@ pub use capability::registry::CapabilityRegistry; // ── Re-exports: runtime ─────────────────────────────────────── +pub use executor::prompt::PlatformInfo; pub use runtime::conversation::ConversationManager; pub use runtime::manager::ThreadManager; pub use runtime::messaging::ThreadOutcome; diff --git a/crates/ironclaw_engine/src/runtime/mission.rs b/crates/ironclaw_engine/src/runtime/mission.rs index e4beddd7..1d742e11 100644 --- a/crates/ironclaw_engine/src/runtime/mission.rs +++ b/crates/ironclaw_engine/src/runtime/mission.rs @@ -604,6 +604,21 @@ impl MissionManager { ) .await?; + // 4. Expected behavior (user feedback loop) + self.ensure_mission_by_metadata( + project_id, + "expected_behavior", + "expected-behavior", + EXPECTED_BEHAVIOR_GOAL, + MissionCadence::OnSystemEvent { + source: "user_feedback".into(), + event_type: "expected_behavior".into(), + }, + "Investigate user-reported expectation gaps and apply fixes", + 5, // max 5/day + ) + .await?; + Ok(()) } @@ -1013,85 +1028,8 @@ fn extract_json_from_response(response: &str) -> Option { /// This is the "program.md" — a concrete, step-by-step prompt that tells the /// agent exactly what to do. Inspired by karpathy/autoresearch: the entire /// research org is a markdown file with an explicit loop. -const SELF_IMPROVEMENT_GOAL: &str = "\ -You are a self-improvement agent for the IronClaw engine. You receive trigger \ -payloads containing execution trace issues from completed threads. Your job is \ -to diagnose root causes and apply fixes so the same issue doesn't recur. - -## What you have access to - -- `state[\"trigger_payload\"]` — JSON with `issues` (list of {severity, category, description, step}), \ - `error_messages` (actual error text from failed actions), `goal` (what the thread was trying to do), \ - and `source_thread_id`. -- All tools: shell, read_file, write_file, apply_patch, web_search, memory_write, etc. -- The codebase at the current working directory. -- The fix pattern database in prior knowledge (if loaded). - -## The experiment loop - -For each issue in the trigger payload: - -1. **Diagnose**: Read the error messages and issue descriptions. Classify the root cause: - - PROMPT: The LLM made a mistake because the system prompt is missing a rule \ - (wrong tool name, bad API usage, ignoring tool results) - - CONFIG: A default value is wrong (truncation length, iteration limit, timeout) - - CODE: There is a bug in the engine or bridge code (crash, type error, missing conversion) - -2. **Check the fix pattern database** in prior knowledge. Has this pattern been seen before? \ - If yes, apply the known strategy. If no, proceed to step 3. - -3. **Apply the fix** based on the level: - - Level 1 (PROMPT — low risk, apply directly): - - Read the current prompt overlay: `memory_search(\"prompt:codeact_preamble\")` - - Write an updated overlay with a new rule appended - - Use `memory_write` with title=\"prompt:codeact_preamble\" and tags=[\"prompt_overlay\"] - - The rule should be specific and actionable (e.g. \"Never call web_fetch — use http() instead\") - - Level 2 (CONFIG — medium risk): - - Use `read_file` to find the relevant constant or default - - Use `shell` to create a git branch: `git checkout -b self-improve/issue-description` - - Apply the change with `apply_patch` or `write_file` - - Run tests: `cargo test -p ironclaw_engine` - - If tests pass, commit. If not, revert: `git checkout main` - - Level 3 (CODE — high risk, just propose): - - Read the relevant source files - - Describe the fix needed but DO NOT apply it directly - - Log it as a recommendation in your FINAL() response - -4. **Record what you did** — include in your FINAL() response: - - What issue you analyzed - - What level fix you applied (1/2/3) - - What specific change you made - - Next focus: what to look for next time - -## Important rules - -- Be specific. \"Never call web_fetch\" is good. \"Be careful with tool names\" is useless. -- One fix per issue. Don't try to fix everything at once. -- For Level 1 fixes, the rule must be one sentence that can be appended to the prompt. -- If the trigger payload has no actionable issues (only Info severity), skip and call FINAL() immediately. -- NEVER modify test files to make a fix pass. -- NEVER modify security-sensitive code (safety layer, policy engine, leak detection). -- If you can't diagnose the root cause after reading the errors, log it and move on. - -## Level 1.5: Orchestrator patches (medium risk, auto-rollback) - -The execution loop itself is Python code that you can modify. This is the \ -orchestrator — it handles tool dispatch, output formatting, state management, \ -and context building. If the bug is in the glue between the LLM and tools \ -(wrong output format, bad truncation, missing state), you can patch it directly. - -To modify the orchestrator: -1. Read current version: `memory_search(\"orchestrator:main\")` -2. Make your change (keep it minimal — one fix at a time) -3. Save the new version: `memory_write` with title=\"orchestrator:main\", \ - tags=[\"orchestrator_code\"], metadata={\"version\": N+1, \"parent_version\": N} -4. The next thread will use your updated orchestrator - -If your change causes 3 consecutive failures, the system auto-rolls back to \ -the previous version. So be conservative — test your logic mentally before saving."; +const SELF_IMPROVEMENT_GOAL: &str = + include_str!("../../prompts/mission_self_improvement.md"); /// Well-known title for the fix pattern database. pub const FIX_PATTERN_DB_TITLE: &str = "fix_pattern_database"; @@ -1100,124 +1038,16 @@ pub const FIX_PATTERN_DB_TITLE: &str = "fix_pattern_database"; pub const FIX_PATTERN_DB_TAG: &str = "fix_patterns"; /// The goal for the skill extraction mission. -const SKILL_EXTRACTION_GOAL: &str = "\ -You extract reusable skills 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 - -## Output Format - -Save as a Skill memory doc via `memory_write(target=\"memory\", content=skill_prompt)` with: -- title: `\"skill:\"` (e.g., \"skill:github-issue-triage\") -- doc_type: `\"skill\"` -- metadata JSON: - ```json - { - \"name\": \"\", - \"version\": 1, - \"description\": \"\", - \"activation\": { - \"keywords\": [\"\", \"\"], - \"patterns\": [\"\"], - \"tags\": [\"\"], - \"exclude_keywords\": [], - \"max_context_tokens\": - }, - \"source\": \"extracted\", - \"trust\": \"trusted\", - \"code_snippets\": [ - { - \"name\": \"\", - \"code\": \"def (...):\\n ...\", - \"description\": \"\" - } - ], - \"metrics\": {\"usage_count\": 0, \"success_count\": 0, \"failure_count\": 0}, - \"content_hash\": \"\" - } - ``` - -## Process - -1. Search for the source thread's context: `memory_search(query=goal)` -2. Check for existing skills: `memory_search(query=\"skill:\")` -3. If a similar skill exists, update it (increment version) rather than creating a duplicate -4. Extract: - - Activation keywords from the goal + user messages (be specific, not generic) - - Step-by-step instructions as the prompt content - - Python code snippets for CodeAct (reusable functions using exact tool names) - - Domain tags (e.g., \"github\", \"api\", \"data\") - -## Output (FINAL) - -Report what you did: -- The skill title and a one-line summary -- Whether it is new or an update to an existing skill -- Next focus: what patterns to watch for - -## Rules - -- Only extract skills from threads with 3+ distinct tool calls -- Keywords must be specific (not generic words like \"help\", \"do\", \"make\") -- Code snippets must use exact tool function names as they appear in the thread -- If the thread was a trivial query-response, call FINAL(\"No skill needed — simple interaction\") \ - and stop immediately -- One skill per FINAL — do not combine unrelated procedures -"; +const SKILL_EXTRACTION_GOAL: &str = + include_str!("../../prompts/mission_skill_extraction.md"); /// 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. +const CONVERSATION_INSIGHTS_GOAL: &str = + include_str!("../../prompts/mission_conversation_insights.md"); -## 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::\" - -## 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 -"; +/// The goal for the expected-behavior mission (user feedback loop). +const EXPECTED_BEHAVIOR_GOAL: &str = + include_str!("../../prompts/mission_expected_behavior.md"); /// Seed content for the fix pattern database. const SEED_FIX_PATTERNS: &str = "\ diff --git a/docs/development-history.md b/docs/development-history.md index fc281871..fa8ae414 100644 --- a/docs/development-history.md +++ b/docs/development-history.md @@ -158,6 +158,42 @@ Users reported `"No lease for action 'routine_create'"` when asking the engine t **Fix**: Registered `mission_create`, `mission_list`, `mission_fire`, `mission_pause`, `mission_resume`, `mission_delete` as a `"missions"` capability in `router.rs`. Descriptions mention "routine" so the LLM maps user intent correctly. Removed all `routine_*` aliases from the effect adapter — `routine_*` names added to `is_v1_only_tool()` blocklist with clear error directing to `mission_*`. +## Session 9: Trace Pipeline Fix, Monty Builtins, Self-Awareness (2026-03-28) + +Three fixes driven by analyzing a live engine trace (`engine_trace_20260328T030519.json`) from the hourly Iran-region monitor mission. + +### Event Pipeline Loss in CodeAct + +**The bug**: The `no_tools_used` trace issue fired as a false positive — the mission thread called `web_search` 5 times, `llm_context` once, and `llm_query` once, yet the trace had zero `ActionExecuted` events. + +**Root cause**: `handle_execute_code_step()` in `orchestrator.rs` received `CodeExecutionResult::events` (populated by `dispatch_action()` in `scripting.rs`) but never transferred them to `thread.events` or broadcast them via `event_tx`. The function took `&Thread` (immutable) and had no access to the event broadcast channel. Compare with `handle_execute_action()` which correctly calls `emit_and_record()` for each action. + +**Fix**: Changed `handle_execute_code_step()` to take `&mut Thread` + `event_tx`, iterate over `result.events`, push each to `thread.events` and broadcast via `event_tx` — same pattern as `handle_execute_action()`. The `no_tools_used` detector in `trace.rs` now works correctly for CodeAct because `ActionExecuted` events are present. + +### globals() NameError in Monty + +**The bug**: LLM-generated code used `"mission_create" in globals()` to probe available capabilities before calling them. Monty doesn't implement `globals()` as a builtin, so NameLookup returned `Undefined` → NameError → code execution failure. + +**Fix**: Added `globals`/`locals` to the NameLookup handler as callable function stubs, and a FunctionCall handler that returns a `Dict` of all known action names (from capability leases) as keys. Code like `"tool_name" in globals()` now works for capability probing. + +### Platform Self-Awareness + +**The problem**: The agent had no knowledge of its own identity. It didn't know it was IronClaw, its GitHub repo, its version, active channels, LLM backend, or database. The system prompt just said "You are IronClaw Agent, a secure autonomous assistant" with no specifics. + +**The insight**: Identity infrastructure was 85% built — `IDENTITY.md`, `SOUL.md`, `USER.md`, `AGENTS.md` injection worked for *user* identity. But nothing existed for *platform* identity. This isn't workspace-level (it changes with runtime config), so a seed file was wrong — it needed to be injected dynamically. + +**Implementation** (8 files): + +1. **`PlatformInfo` struct** (`executor/prompt.rs`) — version, llm_backend, model_name, database_backend, active_channels, owner_id, repo_url. `to_prompt_section()` renders a `## Platform` block. + +2. **CodeAct path** — `build_codeact_system_prompt()` accepts optional `PlatformInfo`, injects before tool listing. + +3. **Tier 0 path** — `Reasoning` struct gets `with_platform_info()` builder, `build_runtime_section()` prepends the platform block. + +4. **Runtime wiring** — `Agent::platform_info()` constructs from `AgentDeps` (version from `CARGO_PKG_VERSION`, backend/model/owner from deps, channels from `ChannelManager`). + +**Test coverage**: 2 new tests (platform info injection + absence). 195 engine tests pass, zero clippy warnings. + ## Architecture Evolution ``` @@ -171,6 +207,8 @@ Session 7: Integration scaling: Capabilities as knowledge → http action (not Pica-style per-action tools — tool list bloat kills LLM accuracy) Session 8: Skills-based OAuth (credential specs in YAML frontmatter) + HTTP tool zero-leak hardening + mission capability leases +Session 9: CodeAct event pipeline fix (ActionExecuted events were lost) + + Monty globals() builtin + platform self-awareness injection ``` ## Key Commits diff --git a/src/agent/CLAUDE.md b/src/agent/CLAUDE.md index 686753de..4067c560 100644 --- a/src/agent/CLAUDE.md +++ b/src/agent/CLAUDE.md @@ -144,6 +144,7 @@ All commands parsed by `SubmissionParser::parse()`: | `/heartbeat` | `Heartbeat` | | | `/summarize`, `/summary` | `Summarize` | | | `/suggest` | `Suggest` | | +| `/expected ` | `Expected` | Fires self-improvement with conversation context | | `/new`, `/thread new` | `NewThread` | | | `/thread ` | `SwitchThread` | Must be valid UUID | | `/resume ` | `Resume` | Must be valid UUID | diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 71c4cba3..0b9c423f 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -203,6 +203,9 @@ pub struct Agent { /// the engine to gateway/manual trigger entry points. pub(super) routine_engine_slot: Arc>>>, + /// Engine v2 mission manager for firing learning missions (set after engine init). + pub(crate) mission_manager_slot: + Arc>>>, } 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> { + pub(super) async fn routine_engine(&self) -> Option> { 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) { + *self.mission_manager_slot.write().await = Some(mgr); + } + + pub(crate) async fn mission_manager( + &self, + ) -> Option> { + 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 { &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 } diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 643d8c7c..2f7c17ac 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -472,6 +472,109 @@ impl Agent { } } + /// Handle `/expected ` — 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>, + thread_id: Uuid, + description: &str, + user_id: &str, + ) -> Result { + // 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 = thread + .turns + .iter() + .rev() + .take(5) + .collect::>() + .into_iter() + .rev() + .map(|turn| { + let tool_calls: Vec = 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, diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index a5f9cd6f..82910246 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -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. diff --git a/src/agent/submission.rs b/src/agent/submission.rs index 5a81e0bf..98e702ed 100644 --- a/src/agent/submission.rs +++ b/src/agent/submission.rs @@ -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 { .. })); + } } diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 6e078ac7..b856e816 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -377,6 +377,8 @@ pub struct Reasoning { /// Channel-specific conversation context (e.g., sender number, UUID, group ID). /// This is passed to the LLM to provide clarity about who/group it's talking to. conversation_context: std::collections::HashMap, + /// Platform identity and runtime metadata for self-awareness. + platform_info: Option, } impl Reasoning { @@ -390,6 +392,7 @@ impl Reasoning { model_name: None, is_group_chat: false, conversation_context: std::collections::HashMap::new(), + platform_info: None, } } @@ -424,6 +427,12 @@ impl Reasoning { self } + /// Set platform metadata for self-awareness in system prompts. + pub fn with_platform_info(mut self, info: ironclaw_engine::PlatformInfo) -> Self { + self.platform_info = Some(info); + self + } + /// Set the model name for runtime context. pub fn with_model_name(mut self, name: impl Into) -> Self { let n = name.into(); @@ -1078,6 +1087,13 @@ Examples (tool calls use JSON format):\n\ } fn build_runtime_section(&self) -> String { + // Platform identity section (self-awareness) + let platform_section = if let Some(ref info) = self.platform_info { + info.to_prompt_section() + } else { + String::new() + }; + let mut parts = Vec::new(); if let Some(ref ch) = self.channel { parts.push(format!("channel={}", ch)); @@ -1085,10 +1101,13 @@ Examples (tool calls use JSON format):\n\ if let Some(ref model) = self.model_name { parts.push(format!("model={}", model)); } - if parts.is_empty() { - return String::new(); - } - format!("\n\n## Runtime\n{}", parts.join(" | ")) + let runtime = if parts.is_empty() { + String::new() + } else { + format!("\n\n## Runtime\n{}", parts.join(" | ")) + }; + + format!("{platform_section}{runtime}") } fn build_conversation_section(&self) -> String {