Implement workflow MVP
This commit is contained in:
@@ -251,19 +251,15 @@ impl PodController {
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
let scope_with_bash = manifest::Scope::from_config(&scope_config)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let scope_with_bash =
|
||||
manifest::Scope::from_config(&scope_config).map_err(std::io::Error::other)?;
|
||||
let fs = tools::ScopedFs::new(scope_with_bash, pwd_for_tools.clone());
|
||||
let tracker = tools::Tracker::new();
|
||||
// The same ScopedFs also powers the IPC `ListCompletions`
|
||||
// query — keep a clone for the FS view we attach below,
|
||||
// since the tools consume `fs` itself.
|
||||
fs_for_view = fs.clone();
|
||||
worker.register_tools(tools::builtin_tools(
|
||||
fs,
|
||||
tracker.clone(),
|
||||
bash_output_dir,
|
||||
));
|
||||
worker.register_tools(tools::builtin_tools(fs, tracker.clone(), bash_output_dir));
|
||||
|
||||
// Memory subsystem opt-in. When `[memory]` is present in
|
||||
// the manifest, register the memory-specific Read/Write/Edit
|
||||
@@ -316,6 +312,12 @@ impl PodController {
|
||||
shared_state.update_history(pod.worker().history().to_vec());
|
||||
shared_state.set_user_segments(pod.user_segments().to_vec());
|
||||
shared_state.set_fs_view(crate::fs_view::PodFsView::new(fs_for_view));
|
||||
shared_state.set_workflows(
|
||||
pod.workflow_completions()
|
||||
.into_iter()
|
||||
.map(|slug| crate::shared_state::WorkflowCandidate { slug })
|
||||
.collect(),
|
||||
);
|
||||
runtime_dir.write_manifest(&manifest_toml).await?;
|
||||
runtime_dir.write_status(&shared_state).await?;
|
||||
runtime_dir.write_history(&shared_state).await?;
|
||||
@@ -360,6 +362,14 @@ impl PodController {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let was_paused = status_before == PodStatus::Paused;
|
||||
if let Err(e) = pod.validate_workflow_invocations(&input) {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: e.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Broadcast the accepted user message so every
|
||||
// subscriber (including the submitter) can
|
||||
// render the turn header + user line from a
|
||||
@@ -369,7 +379,6 @@ impl PodController {
|
||||
let _ = event_tx.send(Event::UserMessage {
|
||||
segments: input.clone(),
|
||||
});
|
||||
let was_paused = status_before == PodStatus::Paused;
|
||||
shared_state.set_status(PodStatus::Running);
|
||||
let _ = runtime_dir.write_status(&shared_state).await;
|
||||
|
||||
|
||||
@@ -647,6 +647,7 @@ permission = "write"
|
||||
tool_names: Vec::new(),
|
||||
agents_md: None,
|
||||
resident_knowledge: None,
|
||||
resident_workflows: None,
|
||||
prompts: &catalog,
|
||||
};
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
@@ -102,11 +102,16 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
||||
is_dir: c.is_dir,
|
||||
})
|
||||
.collect(),
|
||||
// Knowledge / Workflow resolvers are not wired
|
||||
// up yet — reply empty so the TUI sees a
|
||||
// consistent shape regardless of kind.
|
||||
protocol::CompletionKind::Knowledge
|
||||
| protocol::CompletionKind::Workflow => Vec::new(),
|
||||
protocol::CompletionKind::Knowledge => Vec::new(),
|
||||
protocol::CompletionKind::Workflow => handle
|
||||
.shared_state
|
||||
.list_workflow_completions(&prefix)
|
||||
.into_iter()
|
||||
.map(|c| protocol::CompletionEntry {
|
||||
value: c.slug,
|
||||
is_dir: false,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if writer
|
||||
.write(&Event::Completions { kind, entries })
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod prompt;
|
||||
pub mod runtime;
|
||||
pub mod shared_state;
|
||||
pub mod spawn;
|
||||
pub mod workflow;
|
||||
|
||||
mod factory;
|
||||
mod interrupt_and_run;
|
||||
|
||||
+117
-23
@@ -27,6 +27,7 @@ use crate::prompt::loader::PromptLoader;
|
||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::runtime::dir;
|
||||
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
||||
use crate::workflow::WorkflowResolveError;
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::interceptor::PreRequestAction;
|
||||
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||
@@ -124,6 +125,12 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||
/// [`Self::from_manifest`], or defaults to the builtin pack when a
|
||||
/// Pod is constructed through lower-level paths that have no loader.
|
||||
prompts: Arc<PromptCatalog>,
|
||||
/// Registry loaded from `<workspace>/.insomnia/memory/workflow/*.md`
|
||||
/// when memory is enabled. Missing memory config keeps this empty.
|
||||
workflow_registry: memory::WorkflowRegistry,
|
||||
/// Memory workspace layout used by the workflow resolver to load required
|
||||
/// Knowledge records by exact slug.
|
||||
memory_layout: Option<memory::WorkspaceLayout>,
|
||||
/// When true (default), the system-prompt assembler walks
|
||||
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
|
||||
/// section listing records with `model_invokation: true`.
|
||||
@@ -200,6 +207,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
scope_allocation: None,
|
||||
callback_socket: None,
|
||||
prompts,
|
||||
workflow_registry: memory::WorkflowRegistry::empty(),
|
||||
memory_layout: None,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
@@ -565,23 +574,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
// Owned `Vec` lives for the duration of `render` below; the
|
||||
// context borrows a slice into it.
|
||||
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
|
||||
self.manifest
|
||||
.memory
|
||||
self.memory_layout
|
||||
.as_ref()
|
||||
.map(|mem| {
|
||||
let layout = memory::WorkspaceLayout::resolve(mem, &self.pwd);
|
||||
memory::collect_resident_knowledge(&layout)
|
||||
})
|
||||
.map(memory::collect_resident_knowledge)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
|
||||
if self.inject_resident_knowledge && self.manifest.memory.is_some() {
|
||||
if self.inject_resident_knowledge && self.memory_layout.is_some() {
|
||||
Some(&resident)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resident_workflows: Vec<memory::ResidentWorkflowEntry> =
|
||||
if self.inject_resident_knowledge && self.memory_layout.is_some() {
|
||||
self.workflow_registry.resident_entries()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let resident_workflow_slice: Option<&[memory::ResidentWorkflowEntry]> =
|
||||
if self.inject_resident_knowledge && self.memory_layout.is_some() {
|
||||
Some(&resident_workflows)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ctx = SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: &self.pwd,
|
||||
@@ -589,6 +606,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
tool_names,
|
||||
agents_md: agents_md_read.body,
|
||||
resident_knowledge: resident_slice,
|
||||
resident_workflows: resident_workflow_slice,
|
||||
prompts: &self.prompts,
|
||||
};
|
||||
let rendered = template
|
||||
@@ -636,11 +654,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
.await?;
|
||||
self.user_segments.push(input.clone());
|
||||
|
||||
// Resolve `@<path>` refs to system messages stashed for the
|
||||
// PodInterceptor to attach right after the user message. Failures
|
||||
// surface as user-facing Alerts and the placeholder remains in
|
||||
// the flattened text so the LLM sees the unresolved intent.
|
||||
let attachments = self.resolve_file_refs(&input);
|
||||
// Resolve `@<path>` refs and `/<slug>` workflow invocations to
|
||||
// system messages stashed for the PodInterceptor to attach right
|
||||
// after the user message. File failures are non-fatal alerts; explicit
|
||||
// workflow invocation failures abort before the Worker sees the turn.
|
||||
let mut attachments = self.resolve_file_refs(&input);
|
||||
attachments.extend(self.resolve_workflow_invocations(&input)?);
|
||||
if !attachments.is_empty() {
|
||||
*self
|
||||
.pending_attachments
|
||||
@@ -690,6 +709,63 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
out
|
||||
}
|
||||
|
||||
fn resolve_workflow_invocations(
|
||||
&self,
|
||||
segments: &[Segment],
|
||||
) -> Result<Vec<Item>, WorkflowResolveError> {
|
||||
let Some(layout) = self.memory_layout.as_ref() else {
|
||||
if let Some(slug) = segments.iter().find_map(|seg| match seg {
|
||||
Segment::WorkflowInvoke { slug } => Some(slug.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return Err(WorkflowResolveError::NotFound { slug });
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for seg in segments {
|
||||
let Segment::WorkflowInvoke { slug } = seg else {
|
||||
continue;
|
||||
};
|
||||
let items = crate::workflow::resolve_workflow_invocation(
|
||||
&self.workflow_registry,
|
||||
layout,
|
||||
slug,
|
||||
)?;
|
||||
out.extend(items);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validate explicit workflow invocations without reading dependency
|
||||
/// bodies. Used by the controller before broadcasting `UserMessage` so
|
||||
/// user-invocation errors are returned immediately and never reach the
|
||||
/// Worker or client history.
|
||||
pub fn validate_workflow_invocations(
|
||||
&self,
|
||||
segments: &[Segment],
|
||||
) -> Result<(), WorkflowResolveError> {
|
||||
for seg in segments {
|
||||
let Segment::WorkflowInvoke { slug } = seg else {
|
||||
continue;
|
||||
};
|
||||
let parsed =
|
||||
memory::Slug::parse(slug.clone()).map_err(WorkflowResolveError::InvalidSlug)?;
|
||||
let record = self
|
||||
.workflow_registry
|
||||
.get(&parsed)
|
||||
.ok_or_else(|| WorkflowResolveError::NotFound { slug: slug.clone() })?;
|
||||
if !record.user_invocable {
|
||||
return Err(WorkflowResolveError::NotUserInvocable { slug: slug.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workflow_completions(&self) -> Vec<String> {
|
||||
self.workflow_registry.list_user_invocable("")
|
||||
}
|
||||
|
||||
/// Flatten a typed segment list into the single string the Worker
|
||||
/// receives as the user message, and emit user-facing alerts for
|
||||
/// segments that fall through to placeholder (knowledge / workflow
|
||||
@@ -711,16 +787,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
),
|
||||
);
|
||||
}
|
||||
Segment::WorkflowInvoke { slug } => {
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Pod,
|
||||
format!(
|
||||
"workflow /{slug} cannot be resolved \
|
||||
(resolver not yet implemented); passed to LLM as placeholder"
|
||||
),
|
||||
);
|
||||
}
|
||||
Segment::WorkflowInvoke { .. } => {}
|
||||
Segment::Unknown => {
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
@@ -1652,7 +1719,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
||||
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
|
||||
worker.register_tool(memory::tool::knowledge_query_tool(layout.clone(), query_cfg));
|
||||
worker.register_tool(memory::tool::knowledge_query_tool(
|
||||
layout.clone(),
|
||||
query_cfg,
|
||||
));
|
||||
|
||||
let tidy = consolidate::collect_tidy_hints(&layout);
|
||||
let candidates = consolidate::KnowledgeCandidateReport::empty();
|
||||
@@ -1809,6 +1879,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: None,
|
||||
prompts: common.prompts,
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
@@ -1867,6 +1939,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: Some(callback_socket),
|
||||
prompts: common.prompts,
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
@@ -1977,6 +2051,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: None,
|
||||
prompts: common.prompts,
|
||||
workflow_registry: common.workflow_registry,
|
||||
memory_layout: common.memory_layout,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
@@ -2184,6 +2260,12 @@ pub enum PodError {
|
||||
#[error("memory Phase 2 lock acquisition failed: {0}")]
|
||||
ConsolidationLock(#[source] memory::consolidate::LockError),
|
||||
|
||||
#[error("workflow load failed: {0}")]
|
||||
WorkflowLoad(#[source] memory::WorkflowLoadError),
|
||||
|
||||
#[error("workflow invocation failed: {0}")]
|
||||
WorkflowResolve(#[from] WorkflowResolveError),
|
||||
|
||||
#[error("session {session_id} has no entries to restore")]
|
||||
SessionEmpty { session_id: SessionId },
|
||||
}
|
||||
@@ -2197,6 +2279,8 @@ struct PodCommon {
|
||||
scope: Scope,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<PromptCatalog>,
|
||||
workflow_registry: memory::WorkflowRegistry,
|
||||
memory_layout: Option<memory::WorkspaceLayout>,
|
||||
system_prompt_template: Option<SystemPromptTemplate>,
|
||||
}
|
||||
|
||||
@@ -2223,6 +2307,14 @@ fn prepare_pod_common(
|
||||
|
||||
let client = provider::build_client(&manifest.model)?;
|
||||
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
|
||||
let memory_layout = manifest
|
||||
.memory
|
||||
.as_ref()
|
||||
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
|
||||
let workflow_registry = match memory_layout.as_ref() {
|
||||
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
|
||||
None => memory::WorkflowRegistry::empty(),
|
||||
};
|
||||
|
||||
let system_prompt_template = if parse_template {
|
||||
Some(
|
||||
@@ -2238,6 +2330,8 @@ fn prepare_pod_common(
|
||||
scope,
|
||||
client,
|
||||
prompts,
|
||||
workflow_registry,
|
||||
memory_layout,
|
||||
system_prompt_template,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ pub enum PodPrompt {
|
||||
/// AGENTS.md section when memory is enabled and at least one
|
||||
/// `knowledge/*` record advertises `model_invokation: true`.
|
||||
ResidentKnowledgeSection,
|
||||
/// Trailing `## Resident workflows` section, appended after resident
|
||||
/// knowledge when memory is enabled and at least one workflow advertises
|
||||
/// `model_invokation: true`.
|
||||
ResidentWorkflowsSection,
|
||||
}
|
||||
|
||||
impl PodPrompt {
|
||||
@@ -91,6 +95,7 @@ impl PodPrompt {
|
||||
Self::WorkingBoundariesSection => "working_boundaries_section",
|
||||
Self::AgentsMdSection => "agents_md_section",
|
||||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
||||
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +110,7 @@ impl PodPrompt {
|
||||
PodPrompt::WorkingBoundariesSection,
|
||||
PodPrompt::AgentsMdSection,
|
||||
PodPrompt::ResidentKnowledgeSection,
|
||||
PodPrompt::ResidentWorkflowsSection,
|
||||
];
|
||||
|
||||
pub const KEYS: &'static [&'static str] = &[
|
||||
@@ -115,6 +121,7 @@ impl PodPrompt {
|
||||
"working_boundaries_section",
|
||||
"agents_md_section",
|
||||
"resident_knowledge_section",
|
||||
"resident_workflows_section",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -330,6 +337,15 @@ impl PromptCatalog {
|
||||
single("entries", entries),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render `PodPrompt::ResidentWorkflowsSection` with `{{ entries }}`
|
||||
/// (a pre-formatted list block authored by the caller).
|
||||
pub fn resident_workflows_section(&self, entries: &str) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
PodPrompt::ResidentWorkflowsSection,
|
||||
single("entries", entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn single(key: &'static str, value: &str) -> Value {
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use manifest::Scope;
|
||||
use memory::ResidentKnowledgeEntry;
|
||||
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry};
|
||||
use minijinja::value::Value;
|
||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||
use thiserror::Error;
|
||||
@@ -122,6 +122,7 @@ impl SystemPromptTemplate {
|
||||
ctx.scope,
|
||||
ctx.agents_md.as_deref(),
|
||||
ctx.resident_knowledge,
|
||||
ctx.resident_workflows,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -153,6 +154,10 @@ pub struct SystemPromptContext<'a> {
|
||||
/// section entirely (memory disabled, or a Phase 2 worker that opts
|
||||
/// out); `Some(&[])` also yields no section.
|
||||
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
|
||||
/// Resident workflow descriptions from `<workspace>/memory/workflow/*`
|
||||
/// whose frontmatter has `model_invokation: true`. `None` disables the
|
||||
/// section; Phase 2 workers opt out together with resident Knowledge.
|
||||
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,
|
||||
/// Catalog used to render the fixed trailing section headers.
|
||||
/// Passed by reference so callers do not give up ownership across
|
||||
/// the short-lived render borrow.
|
||||
@@ -201,6 +206,7 @@ pub fn append_trailing_section(
|
||||
scope: &Scope,
|
||||
agents_md: Option<&str>,
|
||||
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
||||
resident_workflows: Option<&[ResidentWorkflowEntry]>,
|
||||
) -> Result<String, SystemPromptError> {
|
||||
let mut out = String::with_capacity(body.len() + 256);
|
||||
out.push_str(body);
|
||||
@@ -227,6 +233,15 @@ pub fn append_trailing_section(
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if let Some(entries) = resident_workflows {
|
||||
if !entries.is_empty() {
|
||||
out.push('\n');
|
||||
let formatted = format_resident_workflow_entries(entries);
|
||||
let section = prompts.resident_workflows_section(&formatted)?;
|
||||
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
// Canonicalise the tail so the emitted prompt has a single form
|
||||
// regardless of how individual templates chose to end.
|
||||
while out.ends_with('\n') || out.ends_with(' ') {
|
||||
@@ -238,15 +253,31 @@ pub fn append_trailing_section(
|
||||
/// `- <slug>: <description>` per line. Description newlines are folded
|
||||
/// to spaces so a single entry stays on one row in the rendered prompt.
|
||||
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
|
||||
format_resident_entries(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| (e.slug.as_str(), e.description.as_str())),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_resident_workflow_entries(entries: &[ResidentWorkflowEntry]) -> String {
|
||||
format_resident_entries(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| (e.slug.as_str(), e.description.as_str())),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
for (i, (slug, description)) in entries.enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("- ");
|
||||
out.push_str(&e.slug);
|
||||
out.push_str(slug);
|
||||
out.push_str(": ");
|
||||
for ch in e.description.chars() {
|
||||
for ch in description.chars() {
|
||||
if ch == '\n' || ch == '\r' {
|
||||
out.push(' ');
|
||||
} else {
|
||||
@@ -300,6 +331,7 @@ mod tests {
|
||||
tool_names: tools,
|
||||
agents_md,
|
||||
resident_knowledge: None,
|
||||
resident_workflows: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
@@ -316,6 +348,7 @@ mod tests {
|
||||
tool_names: Vec::new(),
|
||||
agents_md: None,
|
||||
resident_knowledge: Some(resident),
|
||||
resident_workflows: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ use session_store::SessionId;
|
||||
|
||||
use crate::fs_view::PodFsView;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkflowCandidate {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Shared state between PodController and runtime directory.
|
||||
///
|
||||
/// Controller updates this in-memory; RuntimeDir writes it to disk.
|
||||
@@ -31,6 +36,7 @@ pub struct PodSharedState {
|
||||
/// (only relevant for unit tests that build a `PodSharedState`
|
||||
/// directly without spinning up a controller).
|
||||
fs_view: OnceLock<PodFsView>,
|
||||
workflows: OnceLock<Vec<WorkflowCandidate>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -57,6 +63,7 @@ impl PodSharedState {
|
||||
history: RwLock::new(Vec::new()),
|
||||
user_segments: RwLock::new(Vec::new()),
|
||||
fs_view: OnceLock::new(),
|
||||
workflows: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +79,23 @@ impl PodSharedState {
|
||||
self.fs_view.get()
|
||||
}
|
||||
|
||||
pub fn set_workflows(&self, workflows: Vec<WorkflowCandidate>) {
|
||||
let _ = self.workflows.set(workflows);
|
||||
}
|
||||
|
||||
pub fn list_workflow_completions(&self, prefix: &str) -> Vec<WorkflowCandidate> {
|
||||
self.workflows
|
||||
.get()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|candidate| candidate.slug.starts_with(prefix))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn user_segments(&self) -> Vec<Vec<Segment>> {
|
||||
self.user_segments
|
||||
.read()
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Pod-side Workflow resolver.
|
||||
//!
|
||||
//! Turns `Segment::WorkflowInvoke { slug }` into system-message attachments:
|
||||
//! dependency Knowledge bodies first, then the Workflow body. Resolution is
|
||||
//! strict for explicit user invocations: missing workflows, non-user-invocable
|
||||
//! workflows, and missing Knowledge requirements are returned as errors before
|
||||
//! the turn is handed to the Worker.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use llm_worker::Item;
|
||||
use memory::schema::split_frontmatter;
|
||||
use memory::{Slug, WorkflowRegistry, WorkspaceLayout};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowResolveError {
|
||||
InvalidSlug(memory::LintError),
|
||||
NotFound {
|
||||
slug: String,
|
||||
},
|
||||
NotUserInvocable {
|
||||
slug: String,
|
||||
},
|
||||
KnowledgeNotFound {
|
||||
workflow: String,
|
||||
slug: String,
|
||||
},
|
||||
KnowledgeRead {
|
||||
workflow: String,
|
||||
slug: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
KnowledgeFrontmatter {
|
||||
workflow: String,
|
||||
slug: String,
|
||||
source: memory::LintError,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkflowResolveError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidSlug(e) => write!(f, "invalid workflow slug: {e}"),
|
||||
Self::NotFound { slug } => write!(f, "workflow /{slug} is not registered"),
|
||||
Self::NotUserInvocable { slug } => {
|
||||
write!(f, "workflow /{slug} is not user-invocable")
|
||||
}
|
||||
Self::KnowledgeNotFound { workflow, slug } => write!(
|
||||
f,
|
||||
"workflow /{workflow} requires missing Knowledge slug `{slug}`"
|
||||
),
|
||||
Self::KnowledgeRead {
|
||||
workflow,
|
||||
slug,
|
||||
source,
|
||||
} => write!(
|
||||
f,
|
||||
"workflow /{workflow} could not read required Knowledge `{slug}`: {source}"
|
||||
),
|
||||
Self::KnowledgeFrontmatter {
|
||||
workflow,
|
||||
slug,
|
||||
source,
|
||||
} => write!(
|
||||
f,
|
||||
"workflow /{workflow} required Knowledge `{slug}` has invalid frontmatter: {source}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorkflowResolveError {}
|
||||
|
||||
pub fn resolve_workflow_invocation(
|
||||
registry: &WorkflowRegistry,
|
||||
layout: &WorkspaceLayout,
|
||||
raw_slug: &str,
|
||||
) -> Result<Vec<Item>, WorkflowResolveError> {
|
||||
let slug = Slug::parse(raw_slug.to_string()).map_err(WorkflowResolveError::InvalidSlug)?;
|
||||
let record = registry
|
||||
.get(&slug)
|
||||
.ok_or_else(|| WorkflowResolveError::NotFound {
|
||||
slug: raw_slug.to_string(),
|
||||
})?;
|
||||
if !record.user_invocable {
|
||||
return Err(WorkflowResolveError::NotUserInvocable {
|
||||
slug: raw_slug.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for req in &record.requires {
|
||||
let path = layout.knowledge_path(req);
|
||||
let raw = std::fs::read_to_string(&path).map_err(|source| {
|
||||
if source.kind() == std::io::ErrorKind::NotFound {
|
||||
WorkflowResolveError::KnowledgeNotFound {
|
||||
workflow: slug.to_string(),
|
||||
slug: req.to_string(),
|
||||
}
|
||||
} else {
|
||||
WorkflowResolveError::KnowledgeRead {
|
||||
workflow: slug.to_string(),
|
||||
slug: req.to_string(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
|
||||
WorkflowResolveError::KnowledgeFrontmatter {
|
||||
workflow: slug.to_string(),
|
||||
slug: req.to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
out.push(Item::system_message(format!(
|
||||
"[Workflow /{} requires Knowledge #{}]\n{}",
|
||||
slug,
|
||||
req,
|
||||
body.trim_end()
|
||||
)));
|
||||
}
|
||||
out.push(Item::system_message(format!(
|
||||
"[Workflow /{}]\n{}",
|
||||
slug,
|
||||
record.body.trim_end()
|
||||
)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(path: &std::path::Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout, WorkflowRegistry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
write(
|
||||
&dir.path().join(".insomnia/knowledge/policy.md"),
|
||||
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\npolicy body\n",
|
||||
);
|
||||
write(
|
||||
&dir.path().join(".insomnia/memory/workflow/run-it.md"),
|
||||
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
|
||||
);
|
||||
let registry = memory::load_workflows(&layout).unwrap();
|
||||
(dir, layout, registry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_requires_before_workflow_body() {
|
||||
let (_dir, layout, registry) = setup();
|
||||
let items = resolve_workflow_invocation(®istry, &layout, "run-it").unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
let first = format!("{:?}", items[0]);
|
||||
let second = format!("{:?}", items[1]);
|
||||
assert!(first.contains("Knowledge #policy"));
|
||||
assert!(first.contains("policy body"));
|
||||
assert!(second.contains("[Workflow /run-it]"));
|
||||
assert!(second.contains("workflow body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_invocable_false_errors() {
|
||||
let (dir, layout, _registry) = setup();
|
||||
write(
|
||||
&dir.path().join(".insomnia/memory/workflow/hidden.md"),
|
||||
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
|
||||
);
|
||||
let registry = memory::load_workflows(&layout).unwrap();
|
||||
let err = resolve_workflow_invocation(®istry, &layout, "hidden").unwrap_err();
|
||||
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_knowledge_errors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
write(
|
||||
&dir.path().join(".insomnia/memory/workflow/bad.md"),
|
||||
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
|
||||
);
|
||||
let registry = memory::load_workflows(&layout).unwrap();
|
||||
let err = resolve_workflow_invocation(®istry, &layout, "bad").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
WorkflowResolveError::KnowledgeNotFound { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user