refactor(workflows): complete skills→workflows rename (internal + wire/FE) [#3324 follow-up] (#3412)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-06-07 05:40:50 -04:00
committed by GitHub
co-authored by Claude
parent 1d5f5924d8
commit d22d9c987e
100 changed files with 1033 additions and 783 deletions
+10
View File
@@ -2116,6 +2116,16 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) {
action_dir,
);
// --- Triggered-workflow subscriber ---
// Install on the always-run serve boot, not only inside `start_channels`
// (skipped for web-chat-only cores with no messaging integrations, and when
// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`). Without this, any workflow
// declaring `triggers:` was silently ignored on web-chat-only desktop
// installs. Idempotent — shares a process-global OnceLock with the
// `start_channels` site so it registers exactly once regardless of which
// path runs first. (Matching only for now; activation handoff still pending.)
crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace_dir);
// --- Approval gate (#1339) ---
// ON by default; opt out with `OPENHUMAN_APPROVAL_GATE=0` (or `false`).
// Prompt-class `external_effect()` tool calls route through
+1 -1
View File
@@ -418,7 +418,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
model_name: &model_name,
agent_id: INTEGRATIONS_AGENT_ID,
tools: &prompt_tools,
skills: agent.skills(),
workflows: agent.workflows(),
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &empty_visible,
+3 -3
View File
@@ -70,9 +70,9 @@ pub struct ParentExecutionContext {
/// dispatcher choice, …).
pub agent_config: AgentConfig,
/// Skills loaded into the parent. Sub-agents that don't strip the
/// skills catalog inherit this list.
pub skills: Arc<Vec<Workflow>>,
/// Workflows loaded into the parent. Sub-agents that don't strip the
/// workflows catalog inherit this list.
pub workflows: Arc<Vec<Workflow>>,
/// Memory context loaded for the current turn. Auto-injected into
/// subagent prompts so they have access to conversation history and
@@ -531,7 +531,7 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
model_name: "test-model",
agent_id: "",
tools: EMPTY_TOOLS,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: crate::openhuman::agent::prompts::LearnedContextData::default(),
visible_tool_names: &EMPTY_FILTER,
@@ -1050,7 +1050,7 @@ impl Agent {
.temperature(effective_temperature)
.workspace_dir(config.workspace_dir.clone())
.action_dir(config.action_dir.clone())
.skills(crate::openhuman::workflows::load_workflow_metadata(
.workflows(crate::openhuman::workflows::load_workflow_metadata(
&config.workspace_dir,
))
.auto_save(config.memory.auto_save)
@@ -32,7 +32,7 @@ impl AgentBuilder {
temperature: None,
workspace_dir: None,
action_dir: None,
skills: None,
workflows: None,
auto_save: None,
post_turn_hooks: Vec::new(),
learning_enabled: false,
@@ -160,8 +160,8 @@ impl AgentBuilder {
}
/// Sets the skills available to the agent.
pub fn skills(mut self, skills: Vec<crate::openhuman::workflows::Workflow>) -> Self {
self.skills = Some(skills);
pub fn workflows(mut self, skills: Vec<crate::openhuman::workflows::Workflow>) -> Self {
self.workflows = Some(skills);
self
}
@@ -517,7 +517,7 @@ impl AgentBuilder {
temperature: self.temperature.unwrap_or(0.7),
workspace_dir,
action_dir,
skills: self.skills.unwrap_or_default(),
workflows: self.workflows.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
last_memory_context: None,
last_turn_citations: Vec::new(),
@@ -109,9 +109,9 @@ impl Agent {
self.temperature
}
/// The agent's loaded skills, if any.
pub fn skills(&self) -> &[crate::openhuman::workflows::Workflow] {
&self.skills
/// The agent's loaded workflows, if any.
pub fn workflows(&self) -> &[crate::openhuman::workflows::Workflow] {
&self.workflows
}
/// Active Composio integrations fetched at session start.
@@ -271,7 +271,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() {
});
let mut agent = make_agent(provider);
agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))];
agent.skills = vec![crate::openhuman::workflows::Workflow {
agent.workflows = vec![crate::openhuman::workflows::Workflow {
name: "demo".into(),
..Default::default()
}];
@@ -283,7 +283,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() {
assert_eq!(agent.workspace_dir(), agent.workspace_dir.as_path());
assert_eq!(agent.model_name(), agent.model_name);
assert_eq!(agent.temperature(), agent.temperature);
assert_eq!(agent.skills().len(), 1);
assert_eq!(agent.workflows().len(), 1);
assert_eq!(
agent.agent_config().max_tool_iterations,
agent.config.max_tool_iterations
@@ -293,7 +293,7 @@ impl Agent {
model_name: &self.model_name,
agent_id: &self.agent_definition_name,
tools: &prompt_tools,
skills: &self.skills,
workflows: &self.workflows,
dispatcher_instructions: &instructions,
learned,
visible_tool_names: &prompt_visible_tool_names,
@@ -273,11 +273,11 @@ impl Agent {
// heuristic and size cap rationale.
let enriched = {
use crate::openhuman::workflows::inject;
let matches = inject::match_workflows(&self.skills, user_message);
let matches = inject::match_workflows(&self.workflows, user_message);
if matches.is_empty() {
log::debug!(
"[skills:inject] no skill matches for user message (skill_catalog_len={})",
self.skills.len()
"[workflows:inject] no skill matches for user message (skill_catalog_len={})",
self.workflows.len()
);
enriched
} else {
@@ -288,7 +288,7 @@ impl Agent {
);
let matched_count = injection.decisions.iter().filter(|d| d.matched).count();
log::info!(
"[skills:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}",
"[workflows:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}",
injection.decisions.len(),
matched_count,
injection.injected_bytes,
@@ -124,7 +124,7 @@ impl Agent {
workspace_dir: self.workspace_dir.clone(),
memory: Arc::clone(&self.memory),
agent_config: self.config.clone(),
skills: Arc::new(self.skills.clone()),
workflows: Arc::new(self.workflows.clone()),
memory_context: Arc::new(self.last_memory_context.clone()),
session_id: self.event_session_id().to_string(),
channel: self.event_channel().to_string(),
@@ -447,7 +447,7 @@ fn trim_history_snaps_past_orphaned_tool_results() {
fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
let mut agent = make_agent(None);
agent.last_memory_context = Some("remember this".into());
agent.skills = vec![crate::openhuman::workflows::Workflow {
agent.workflows = vec![crate::openhuman::workflows::Workflow {
name: "demo".into(),
..Default::default()
}];
@@ -458,7 +458,7 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
assert_eq!(parent.memory_context.as_deref(), Some("remember this"));
assert_eq!(parent.session_id, "turn-test-session");
assert_eq!(parent.channel, "turn-test-channel");
assert_eq!(parent.skills.len(), 1);
assert_eq!(parent.workflows.len(), 1);
assert_eq!(sanitize_learned_entry(" "), "");
assert_eq!(
+2 -2
View File
@@ -56,7 +56,7 @@ pub struct Agent {
pub(super) temperature: f64,
pub(super) workspace_dir: std::path::PathBuf,
pub(super) action_dir: std::path::PathBuf,
pub(super) skills: Vec<crate::openhuman::workflows::Workflow>,
pub(super) workflows: Vec<crate::openhuman::workflows::Workflow>,
/// Agent workflows discovered at session start.
pub(super) auto_save: bool,
/// Last memory context loaded for the current turn. Stored so it can
@@ -275,7 +275,7 @@ pub struct AgentBuilder {
pub(super) temperature: Option<f64>,
pub(super) workspace_dir: Option<std::path::PathBuf>,
pub(super) action_dir: Option<std::path::PathBuf>,
pub(super) skills: Option<Vec<crate::openhuman::workflows::Workflow>>,
pub(super) workflows: Option<Vec<crate::openhuman::workflows::Workflow>>,
/// Agent workflows to surface in the prompt. Populated from `load_workflows`
/// at session start; defaults to empty when not explicitly set.
pub(super) auto_save: Option<bool>,
@@ -563,7 +563,7 @@ async fn run_typed_mode(
model_name: &model,
agent_id: &definition.id,
tools: &prompt_tools,
skills: &parent.skills,
workflows: &parent.workflows,
dispatcher_instructions: &dispatcher_instructions,
learned: crate::openhuman::context::prompt::LearnedContextData::default(),
visible_tool_names: &visible_tool_names,
@@ -326,7 +326,7 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
workspace_dir: std::env::temp_dir(),
memory: noop_memory(),
agent_config: crate::openhuman::config::AgentConfig::default(),
skills: Arc::new(vec![]),
workflows: Arc::new(vec![]),
memory_context: Arc::new(None),
session_id: "test-session".into(),
channel: "test".into(),
@@ -1599,7 +1599,7 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
model_name: "test",
agent_id: "orchestrator",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY.get_or_init(HashSet::new),
+1 -1
View File
@@ -870,7 +870,7 @@ mod tests {
model_name: "test-model",
agent_id: "orchestrator",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible_tool_names,
+15 -15
View File
@@ -58,7 +58,7 @@ fn prompt_builder_assembles_sections() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "instr",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -92,7 +92,7 @@ fn identity_section_creates_missing_workspace_files() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -135,7 +135,7 @@ fn datetime_section_includes_timestamp_and_timezone() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "instr",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -180,7 +180,7 @@ fn ctx_with_identity(identity: Option<UserIdentity>) -> PromptContext<'static> {
model_name: "test-model",
agent_id: "",
tools: EMPTY_TOOLS,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
@@ -322,7 +322,7 @@ fn tools_section_pformat_renders_signature_not_schema() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -365,7 +365,7 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -415,7 +415,7 @@ fn user_memory_section_renders_namespaces_with_headings() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned,
visible_tool_names: &NO_FILTER,
@@ -494,7 +494,7 @@ fn user_memory_section_returns_empty_when_no_summaries() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned,
visible_tool_names: &NO_FILTER,
@@ -1142,7 +1142,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -1188,7 +1188,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -1265,7 +1265,7 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() {
model_name: "model",
agent_id: "",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData {
tree_root_summaries: vec![ns_summary("user", "kept"), ns_summary("empty", " ")],
@@ -1296,7 +1296,7 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> {
model_name: "test-model",
agent_id: "",
tools: prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned,
visible_tool_names: &NO_FILTER,
@@ -1435,7 +1435,7 @@ fn tools_section_empty_for_native() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -1468,7 +1468,7 @@ fn tools_section_nonempty_for_pformat() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -1503,7 +1503,7 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() {
model_name: "test-model",
agent_id: "",
tools: &prompt_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "## Tool Use Protocol\n\nUse native tool calling.",
learned: LearnedContextData::default(),
visible_tool_names: &NO_FILTER,
@@ -640,7 +640,7 @@ pub fn default_workspace_file_content(filename: &str) -> &'static str {
/// manufacture a full context when they only need the static text.
fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
static EMPTY_TOOLS: &[PromptTool<'static>] = &[];
static EMPTY_SKILLS: &[crate::openhuman::workflows::Workflow] = &[];
static EMPTY_WORKFLOWS: &[crate::openhuman::workflows::Workflow] = &[];
static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[];
// SAFETY: the &HashSet reference must outlive the returned context;
// a leaked OnceLock-style allocation gives us a permanent 'static
@@ -652,7 +652,7 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
model_name: "",
agent_id: "",
tools: EMPTY_TOOLS,
skills: EMPTY_SKILLS,
workflows: EMPTY_WORKFLOWS,
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
+1 -1
View File
@@ -339,7 +339,7 @@ pub struct PromptContext<'a> {
/// Id of the agent this prompt is being built for.
pub agent_id: &'a str,
pub tools: &'a [PromptTool<'a>],
pub skills: &'a [Workflow],
pub workflows: &'a [Workflow],
pub dispatcher_instructions: &'a str,
/// Pre-fetched learned context (empty when learning is disabled).
pub learned: LearnedContextData,
+43
View File
@@ -99,6 +99,14 @@ mod guard {
Ok(())
}
/// Test-only reader for the process-lifetime spawn counter. Used by the
/// regression test that asserts a rejected spawn (e.g. unknown workflow
/// id) doesn't consume a backstop slot.
#[cfg(test)]
pub fn total_spawns() -> u64 {
TOTAL_SPAWNS.load(Ordering::SeqCst)
}
/// Acquire an await slot + re-entrancy lock for `key` (a workflow-id +
/// inputs fingerprint, or `await:<run_id>` for re-attach). `Err` if too
/// many awaits are in flight (nesting/fan-out cap) or the same key is
@@ -555,6 +563,41 @@ mod tests {
super::guard::acquire_await("cap-test-9".to_string()).expect("a freed slot is reusable");
}
#[tokio::test]
async fn unknown_workflow_id_does_not_burn_a_spawn_slot() {
// Regression: a rejected spawn (unknown workflow id) must NOT consume a
// slot against the process-lifetime backstop. `account_spawn` runs only
// in the `Ok(started)` arm — after `spawn_workflow_run_background`
// succeeds — so an unknown id (which fails synchronously) never accounts
// a spawn. Without this ordering, an agent retrying a bad id would
// exhaust the 500-spawn budget for legitimate runs. Asserts the counter
// DELTA is zero (the counter is global + monotonic, so absolute value is
// shared with the backstop test — hence the serial lock + delta check).
let _s = guard_serial().lock().unwrap();
let before = super::guard::total_spawns();
let t = RunWorkflowTool::new();
// wait_seconds: 0 → fire-and-forget path (no await slot taken); the
// unknown id makes the spawn fail before accounting.
let res = t
.execute(json!({
"workflow_id": "definitely-not-a-real-workflow-zzz",
"wait_seconds": 0
}))
.await
.expect("Ok(ToolResult)");
assert!(res.is_error, "unknown workflow id must return a tool error");
assert!(
res.output().contains("unknown") || res.output().contains("workflow"),
"error should reference the unknown workflow: {}",
res.output()
);
let after = super::guard::total_spawns();
assert_eq!(
before, after,
"a rejected spawn must not increment the spawn backstop counter"
);
}
#[test]
fn account_spawn_trips_the_process_backstop() {
let _s = guard_serial().lock().unwrap();
+1 -1
View File
@@ -280,7 +280,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
workspace_dir: agent.workspace_dir().to_path_buf(),
memory: agent.memory_arc(),
agent_config: agent.agent_config().clone(),
skills: Arc::new(agent.skills().to_vec()),
workflows: Arc::new(agent.workflows().to_vec()),
memory_context: Arc::new(None), // Sub-agent queries memory via tools if needed
session_id: format!("triage-{}", uuid::Uuid::new_v4()),
channel: "triage".to_string(),
+1 -1
View File
@@ -735,7 +735,7 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option<String> {
model_name: "",
agent_id: &def.id,
tools: &empty_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &empty_visible,
@@ -86,7 +86,7 @@ fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
workspace_dir: std::env::temp_dir(),
memory: Arc::new(NoopMemory),
agent_config: AgentConfig::default(),
skills: Arc::new(Vec::new()),
workflows: Arc::new(Vec::new()),
memory_context: Arc::new(None),
session_id: "orchestrator-session".to_string(),
channel: "test".to_string(),
@@ -201,7 +201,7 @@ fn parent_context_with_provider(
workspace_dir: std::env::temp_dir(),
memory: Arc::new(NoopMemory),
agent_config,
skills: Arc::new(Vec::new()),
workflows: Arc::new(Vec::new()),
memory_context: Arc::new(None),
session_id: "session-test".into(),
channel: "test".into(),
@@ -385,7 +385,7 @@ mod tests {
channel: "test".into(),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
skills: Arc::new(vec![]),
workflows: Arc::new(vec![]),
memory_context: std::sync::Arc::new(None),
connected_integrations: vec![],
on_progress: None,
@@ -193,7 +193,7 @@ fn parent_context(
workspace_dir: workspace_dir.to_path_buf(),
memory: Arc::new(NoopMemory),
agent_config: Default::default(),
skills: Arc::new(Vec::new()),
workflows: Arc::new(Vec::new()),
memory_context: Arc::new(None),
session_id: "tools-e2e-session".into(),
channel: "test".into(),
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "archivist",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -56,7 +56,7 @@ mod tests {
model_name: "test",
agent_id: "code_executor",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -51,7 +51,7 @@ mod tests {
model_name: "test",
agent_id: "critic",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -19,7 +19,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
agent_id = ctx.agent_id,
model = ctx.model_name,
tool_count = ctx.tools.len(),
skill_count = ctx.skills.len(),
workflow_count = ctx.workflows.len(),
"[agent_prompt][crypto_agent] build_start"
);
@@ -77,7 +77,7 @@ mod tests {
model_name: "test",
agent_id: "crypto_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
@@ -47,7 +47,7 @@ mod tests {
model_name: "test",
agent_id: "desktop_control_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -51,7 +51,7 @@ mod tests {
model_name: "test",
agent_id: "help",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -185,7 +185,7 @@ mod tests {
model_name: "test",
agent_id: "integrations_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
@@ -467,7 +467,7 @@ mod tests {
model_name: "test",
agent_id: &def.id,
tools: &empty_tools,
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &empty_visible,
@@ -21,7 +21,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
agent_id = ctx.agent_id,
model = ctx.model_name,
tool_count = ctx.tools.len(),
skill_count = ctx.skills.len(),
workflow_count = ctx.workflows.len(),
"[agent_prompt][markets_agent] build_start"
);
@@ -79,7 +79,7 @@ mod tests {
model_name: "test",
agent_id: "markets_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "mcp_setup",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
@@ -67,7 +67,7 @@ mod tests {
model_name: "test",
agent_id: "morning_briefing",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
@@ -200,7 +200,7 @@ mod tests {
model_name: "test",
agent_id: "orchestrator",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
@@ -58,7 +58,7 @@ mod tests {
model_name: "test",
agent_id: "planner",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -47,7 +47,7 @@ mod tests {
model_name: "test",
agent_id: "presentation_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "researcher",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -47,7 +47,7 @@ mod tests {
model_name: "test",
agent_id: "scheduler_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -101,7 +101,7 @@ mod tests {
model_name: "test",
agent_id: "skill_creator",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "summarizer",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -51,7 +51,7 @@ mod tests {
model_name: "test",
agent_id: "task_manager_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -56,7 +56,7 @@ mod tests {
model_name: "test",
agent_id: "tool_maker",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -53,7 +53,7 @@ mod tests {
model_name: "test",
agent_id: "tools_agent",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "trigger_reactor",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
@@ -52,7 +52,7 @@ mod tests {
model_name: "test",
agent_id: "trigger_triage",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
+4 -10
View File
@@ -322,16 +322,10 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
// Install the triggered-workflow subscriber now that workflows are
// discovered — otherwise any workflow declaring `triggers:` is silently
// ignored in the channel runtime. The handle is parked in a process static
// so the RAII SubscriptionHandle isn't dropped (which would cancel it).
{
use crate::core::event_bus::SubscriptionHandle;
use std::sync::OnceLock;
static TRIGGERED_WORKFLOW_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| {
crate::openhuman::workflows::bus::register_triggered_workflow_subscriber(&skills)
});
}
// ignored. Idempotent + shares a process-global OnceLock with the
// `bootstrap_core_runtime` site, so it registers exactly once regardless of
// which startup path runs first (web-chat-only cores never reach here).
crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace);
// Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![
+1 -1
View File
@@ -296,7 +296,7 @@ mod tests {
model_name: "test-model",
agent_id: "",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned,
visible_tool_names,
+1 -1
View File
@@ -232,7 +232,7 @@ mod tests {
model_name: "test",
agent_id: "test",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
+53 -4
View File
@@ -14,7 +14,7 @@
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
use crate::openhuman::workflows::Workflow;
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
// ── Trigger pattern ───────────────────────────────────────────────────────────
@@ -109,7 +109,7 @@ impl TriggeredWorkflowIndex {
let p = TriggerPattern::parse(t);
if p.is_none() {
log::warn!(
"[skills::triggered] skill '{}': malformed trigger {:?} — skipping",
"[workflows::triggered] skill '{}': malformed trigger {:?} — skipping",
skill.name,
t
);
@@ -184,7 +184,7 @@ impl EventHandler for TriggeredSkillSubscriber {
tracing::debug!(
domain = event.domain(),
skills = ?matched,
"[skills::triggered] event matches {} skill trigger(s); \
"[workflows::triggered] event matches {} skill trigger(s); \
activation handoff to integration layer pending",
matched.len()
);
@@ -213,7 +213,7 @@ pub fn register_triggered_workflow_subscriber(skills: &[Workflow]) -> Option<Sub
return None;
}
log::info!(
"[skills::triggered] registering subscriber for {} skill(s) with event triggers (domains: {:?})",
"[workflows::triggered] registering subscriber for {} skill(s) with event triggers (domains: {:?})",
index.len(),
index.domains()
);
@@ -222,6 +222,39 @@ pub fn register_triggered_workflow_subscriber(skills: &[Workflow]) -> Option<Sub
}))
}
/// Process-global parking spot for the triggered-workflow subscription
/// handle. The RAII [`SubscriptionHandle`] must outlive the process (dropping
/// it cancels the subscription), and registration must happen exactly once no
/// matter how many startup paths reach it.
static TRIGGERED_WORKFLOW_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
/// Idempotently install the triggered-workflow subscriber.
///
/// Loads workflow metadata from `workspace` and registers the subscriber on the
/// **first** call; subsequent calls are no-ops (the handle is parked in
/// [`TRIGGERED_WORKFLOW_HANDLE`] so the RAII guard isn't dropped). Safe to call
/// from every startup path.
///
/// Both [`crate::openhuman::channels::start_channels`] (messaging cores) and
/// [`crate::core::jsonrpc::bootstrap_core_runtime`] (always-run serve boot)
/// invoke this. `start_channels` is skipped for web-chat-only desktop installs
/// (no messaging integration connected) and when
/// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`; registering from
/// `bootstrap_core_runtime` too means those cores still honour workflow
/// `triggers:`. The shared `OnceLock` guarantees a single registration
/// regardless of which path runs first.
///
/// NOTE: the subscriber currently only *matches* triggers and logs — the
/// activation handoff to the integration layer is still pending (see
/// [`TriggeredSkillSubscriber::handle`]). Registering on web-chat-only cores
/// enables matching, not yet activation.
pub fn ensure_triggered_workflow_subscriber(workspace: &std::path::Path) {
TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| {
let workflows = crate::openhuman::workflows::load_workflow_metadata(workspace);
register_triggered_workflow_subscriber(&workflows)
});
}
/// Legacy no-op retained while call-sites migrate to
/// [`register_triggered_workflow_subscriber`]. Safe to call multiple times.
pub fn register_workflow_cleanup_subscriber() {}
@@ -242,6 +275,22 @@ mod tests {
}
}
// ── ensure_triggered_workflow_subscriber (C1 boot-path helper) ───────────
#[test]
fn ensure_triggered_workflow_subscriber_is_idempotent_and_safe() {
// Covers the boot-path helper called from both `start_channels` and
// `bootstrap_core_runtime`: it loads workflow metadata from the
// workspace and registers the subscriber exactly once via the
// process-global OnceLock. An empty temp workspace yields no triggered
// workflows, so registration resolves to `None`; the call must not
// panic and must be safe to repeat (the OnceLock makes the second call
// a no-op).
let tmp = tempfile::tempdir().expect("tempdir");
ensure_triggered_workflow_subscriber(tmp.path());
ensure_triggered_workflow_subscriber(tmp.path());
}
// ── TriggerPattern::parse ────────────────────────────────────────────────
#[test]
+10 -7
View File
@@ -52,7 +52,7 @@
//!
//! ## Logging
//!
//! Every candidate emits a grep-friendly `[skills:inject]` log line
//! Every candidate emits a grep-friendly `[workflows:inject]` log line
//! with `matched=<bool>`, reason, and injected bytes (see
//! [`render_injection`]). A summary line lives in the caller
//! (`Agent::turn`).
@@ -265,7 +265,10 @@ fn contains_whole_word(haystack_lower: &str, needle_lower: &str) -> bool {
/// Match installed skills against a user message per the heuristic
/// documented at the top of this module.
pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec<WorkflowMatch<'a>> {
pub fn match_workflows<'a>(
workflows: &'a [Workflow],
user_message: &str,
) -> Vec<WorkflowMatch<'a>> {
let mentions = extract_mentions(user_message);
let mention_set: HashSet<String> = mentions.iter().map(|(n, _)| n.clone()).collect();
let mention_index = |skill_norm: &str| -> Option<usize> {
@@ -278,7 +281,7 @@ pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec<Wo
let lower_msg = user_message.to_lowercase();
let mut matches: Vec<WorkflowMatch<'a>> = Vec::new();
for skill in skills {
for skill in workflows {
let normalised_name = normalise(&skill.name);
let user_invocable = is_user_invocable(skill);
@@ -385,7 +388,7 @@ where
Some(b) => b,
None => {
log::warn!(
"[skills:inject] matched={} reason={} name={} skipped=body_unavailable",
"[workflows:inject] matched={} reason={} name={} skipped=body_unavailable",
false,
"body_unavailable",
name
@@ -415,7 +418,7 @@ where
let min_truncated = header_len + footer_trunc_len + 1;
if remaining < min_truncated {
log::info!(
"[skills:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}",
"[workflows:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}",
false,
"budget_exhausted",
name,
@@ -439,7 +442,7 @@ where
rendered.push_str(&footer_full);
let injected = header_len + body.len() + footer_full_len;
log::debug!(
"[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={}",
"[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={}",
true,
m.reason.as_str(),
name,
@@ -469,7 +472,7 @@ where
truncated_any = true;
let injected = header_len + truncated_body.len() + footer_trunc_len;
log::warn!(
"[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}",
"[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}",
true,
m.reason.as_str(),
name,
+2 -2
View File
@@ -302,7 +302,7 @@ impl Workflow {
pub fn read_body(&self) -> Option<String> {
if self.legacy {
log::debug!(
"[skills:inject] read_body skipped for legacy skill.json skill name={}",
"[workflows:inject] read_body skipped for legacy skill.json skill name={}",
self.name
);
return None;
@@ -312,7 +312,7 @@ impl Workflow {
Some((_, body, _)) => Some(body),
None => {
log::warn!(
"[skills:inject] read_body failed to parse {} for skill {}",
"[workflows:inject] read_body failed to parse {} for skill {}",
path.display(),
self.name
);
+12 -12
View File
@@ -243,36 +243,36 @@ pub async fn run_github_preflight<P: PreflightProbes>(
probes: &P,
) -> Result<(), GithubGateError> {
let Some(cfg) = cfg else {
tracing::debug!("[skills:preflight] github gate skipped: no [github] block");
tracing::debug!("[workflows:preflight] github gate skipped: no [github] block");
return Ok(());
};
if !cfg.required {
tracing::debug!("[skills:preflight] github gate skipped: required = false");
tracing::debug!("[workflows:preflight] github gate skipped: required = false");
return Ok(());
}
// (1) Composio GitHub integration must be connected.
if !probes.composio_toolkit_active("github").await {
tracing::warn!("[skills:preflight] github gate fail: composio_github_missing");
tracing::warn!("[workflows:preflight] github gate fail: composio_github_missing");
return Err(GithubGateError::ComposioGithubMissing);
}
// (2) git binary present.
if let Err(e) = probes.git_version().await {
tracing::warn!(error = %e, "[skills:preflight] github gate fail: git_binary_missing");
tracing::warn!(error = %e, "[workflows:preflight] github gate fail: git_binary_missing");
return Err(GithubGateError::GitBinaryMissing(e));
}
// (3a) git user.name set.
let git_name = probes.git_user_name().await;
if git_name.is_empty() {
tracing::warn!("[skills:preflight] github gate fail: git_user_name_missing");
tracing::warn!("[workflows:preflight] github gate fail: git_user_name_missing");
return Err(GithubGateError::GitUserNameMissing);
}
// (3b) git user.email set.
let git_email = probes.git_user_email().await;
if git_email.is_empty() {
tracing::warn!("[skills:preflight] github gate fail: git_user_email_missing");
tracing::warn!("[workflows:preflight] github gate fail: git_user_email_missing");
return Err(GithubGateError::GitUserEmailMissing);
}
@@ -280,7 +280,7 @@ pub async fn run_github_preflight<P: PreflightProbes>(
match cfg.identity_match {
IdentityMatch::None => {
tracing::debug!(
"[skills:preflight] github gate pass (identity_match=none, reachability only)"
"[workflows:preflight] github gate pass (identity_match=none, reachability only)"
);
Ok(())
}
@@ -289,12 +289,12 @@ pub async fn run_github_preflight<P: PreflightProbes>(
// identity — confirms the connection is genuinely usable.
match probes.composio_identity("github").await {
Some(_) => {
tracing::debug!("[skills:preflight] github gate pass (identity_match=any)");
tracing::debug!("[workflows:preflight] github gate pass (identity_match=any)");
Ok(())
}
None => {
tracing::warn!(
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=any)"
"[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=any)"
);
Err(GithubGateError::ComposioIdentityUnresolved)
}
@@ -305,7 +305,7 @@ pub async fn run_github_preflight<P: PreflightProbes>(
Some(n) => n,
None => {
tracing::warn!(
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)"
"[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)"
);
return Err(GithubGateError::ComposioIdentityUnresolved);
}
@@ -314,14 +314,14 @@ pub async fn run_github_preflight<P: PreflightProbes>(
tracing::debug!(
composio = %composio_name,
git = %git_name,
"[skills:preflight] github gate pass (identity_match=strict)"
"[workflows:preflight] github gate pass (identity_match=strict)"
);
Ok(())
} else {
tracing::warn!(
composio = %composio_name,
git = %git_name,
"[skills:preflight] github gate fail: identity_mismatch"
"[workflows:preflight] github gate fail: identity_mismatch"
);
Err(GithubGateError::IdentityMismatch {
composio_username: composio_name,
+6 -4
View File
@@ -43,7 +43,9 @@ pub(super) fn handle_workflows_list(params: Map<String, Value>) -> ControllerFut
);
let summaries = skills.into_iter().map(WorkflowSummary::from).collect();
to_json(RpcOutcome::new(
WorkflowsListResult { skills: summaries },
WorkflowsListResult {
workflows: summaries,
},
Vec::new(),
))
})
@@ -231,7 +233,7 @@ pub(super) fn handle_workflows_create(params: Map<String, Value>) -> ControllerF
);
to_json(RpcOutcome::new(
WorkflowsCreateResult {
skill: WorkflowSummary::from(skill),
workflow: WorkflowSummary::from(skill),
},
Vec::new(),
))
@@ -261,7 +263,7 @@ pub(super) fn handle_workflows_update(params: Map<String, Value>) -> ControllerF
match create_workflow(workspace.as_path(), create_params) {
Ok(skill) => to_json(RpcOutcome::new(
WorkflowsCreateResult {
skill: WorkflowSummary::from(skill),
workflow: WorkflowSummary::from(skill),
},
Vec::new(),
)),
@@ -296,7 +298,7 @@ pub(super) fn handle_workflows_install_from_url(params: Map<String, Value>) -> C
url: outcome.url,
stdout: outcome.stdout,
stderr: outcome.stderr,
new_skills: outcome.new_skills,
new_workflows: outcome.new_skills,
},
Vec::new(),
))
@@ -193,7 +193,7 @@ impl From<Workflow> for WorkflowSummary {
#[derive(Debug, Serialize)]
pub(super) struct WorkflowsListResult {
pub(super) skills: Vec<WorkflowSummary>,
pub(super) workflows: Vec<WorkflowSummary>,
}
#[derive(Debug, Serialize)]
@@ -206,7 +206,7 @@ pub(super) struct WorkflowsReadResourceResult {
#[derive(Debug, Serialize)]
pub(super) struct WorkflowsCreateResult {
pub(super) skill: WorkflowSummary,
pub(super) workflow: WorkflowSummary,
}
#[derive(Debug, Serialize)]
@@ -214,7 +214,7 @@ pub(super) struct WorkflowsInstallFromUrlResult {
pub(super) url: String,
pub(super) stdout: String,
pub(super) stderr: String,
pub(super) new_skills: Vec<String>,
pub(super) new_workflows: Vec<String>,
}
#[derive(Debug, Serialize)]