Compare commits
4 Commits
8ed739261f
...
288e2239d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 288e2239d4 | |||
| 8194bb10f4 | |||
| a9ad0c2e0d | |||
| 433ee0f37c |
1
TODO.md
1
TODO.md
|
|
@ -1,5 +1,4 @@
|
||||||
- [ ] Workflow / Skills
|
- [ ] Workflow / Skills
|
||||||
- [ ] Workflow 実装 → [tickets/workflow.md](tickets/workflow.md)
|
|
||||||
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
|
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
|
||||||
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
|
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
|
||||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub mod schema;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
pub mod slug;
|
pub mod slug;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
pub mod workflow;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use error::{LintError, LintWarning, MemoryError};
|
pub use error::{LintError, LintWarning, MemoryError};
|
||||||
|
|
@ -23,4 +24,8 @@ pub use linter::{LintReport, Linter};
|
||||||
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
|
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
|
||||||
pub use scope::deny_write_rules;
|
pub use scope::deny_write_rules;
|
||||||
pub use slug::Slug;
|
pub use slug::Slug;
|
||||||
|
pub use workflow::{
|
||||||
|
ResidentWorkflowEntry, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError, WorkflowRecord,
|
||||||
|
WorkflowRegistry, load_workflows,
|
||||||
|
};
|
||||||
pub use workspace::WorkspaceLayout;
|
pub use workspace::WorkspaceLayout;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||||
"model_invokation",
|
"model_invokation",
|
||||||
"user_invocable",
|
"user_invocable",
|
||||||
"last_sources",
|
"last_sources",
|
||||||
"auto_invoke",
|
|
||||||
"requires",
|
"requires",
|
||||||
];
|
];
|
||||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ use crate::schema::{
|
||||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
|
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
|
||||||
WorkflowFrontmatter, split_frontmatter,
|
WorkflowFrontmatter, split_frontmatter,
|
||||||
};
|
};
|
||||||
|
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
|
||||||
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
pub use existing::{ExistingRecords, scan_existing};
|
pub use existing::{ExistingRecords, scan_existing};
|
||||||
|
|
@ -258,6 +259,18 @@ impl Linter {
|
||||||
};
|
};
|
||||||
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
|
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
|
||||||
|
|
||||||
|
// Mirror the loader's cap so human-edit paths fail fast instead
|
||||||
|
// of surfacing the same error only at Pod startup.
|
||||||
|
if parsed.frontmatter.model_invokation {
|
||||||
|
let actual = parsed.frontmatter.description.chars().count();
|
||||||
|
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
|
||||||
|
report.push_error(LintError::DescriptionTooLong {
|
||||||
|
actual,
|
||||||
|
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let existing = match existing::scan_existing(&self.layout) {
|
let existing = match existing::scan_existing(&self.layout) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -323,10 +336,9 @@ mod tests {
|
||||||
fn workflow_write_rejected() {
|
fn workflow_write_rejected() {
|
||||||
let (dir, linter) = workspace();
|
let (dir, linter) = workspace();
|
||||||
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
|
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
|
||||||
let content = format!(
|
let content =
|
||||||
"---\nupdated_at: {now}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody",
|
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
|
||||||
now = iso_now()
|
.to_string();
|
||||||
);
|
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(
|
assert!(
|
||||||
report
|
report
|
||||||
|
|
@ -499,10 +511,7 @@ mod tests {
|
||||||
n = iso_now()
|
n = iso_now()
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let wf = format!(
|
let wf = "---\ndescription: do thing\nmodel_invokation: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n".to_string();
|
||||||
"---\nupdated_at: {n}\ndescription: do thing\nauto_invoke: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n",
|
|
||||||
n = iso_now()
|
|
||||||
);
|
|
||||||
let report = linter.lint_workflow(&wf);
|
let report = linter.lint_workflow(&wf);
|
||||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||||
}
|
}
|
||||||
|
|
@ -510,10 +519,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_lint_flags_unknown_requires() {
|
fn workflow_lint_flags_unknown_requires() {
|
||||||
let (_dir, linter) = workspace();
|
let (_dir, linter) = workspace();
|
||||||
let wf = format!(
|
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n".to_string();
|
||||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n",
|
|
||||||
n = iso_now()
|
|
||||||
);
|
|
||||||
let report = linter.lint_workflow(&wf);
|
let report = linter.lint_workflow(&wf);
|
||||||
assert!(report.errors.iter().any(|e| matches!(
|
assert!(report.errors.iter().any(|e| matches!(
|
||||||
e,
|
e,
|
||||||
|
|
@ -525,13 +531,42 @@ mod tests {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_lint_flags_long_description_when_model_invokation() {
|
||||||
|
let (_dir, linter) = workspace();
|
||||||
|
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||||
|
let wf = format!(
|
||||||
|
"---\ndescription: {desc}\nmodel_invokation: true\nuser_invocable: true\n---\n"
|
||||||
|
);
|
||||||
|
let report = linter.lint_workflow(&wf);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_lint_allows_long_description_when_not_model_invokation() {
|
||||||
|
let (_dir, linter) = workspace();
|
||||||
|
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||||
|
let wf = format!(
|
||||||
|
"---\ndescription: {desc}\nmodel_invokation: false\nuser_invocable: true\n---\n"
|
||||||
|
);
|
||||||
|
let report = linter.lint_workflow(&wf);
|
||||||
|
assert!(
|
||||||
|
!report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_lint_collects_multiple_unknown_requires() {
|
fn workflow_lint_collects_multiple_unknown_requires() {
|
||||||
let (_dir, linter) = workspace();
|
let (_dir, linter) = workspace();
|
||||||
let wf = format!(
|
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n".to_string();
|
||||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n",
|
|
||||||
n = iso_now()
|
|
||||||
);
|
|
||||||
let report = linter.lint_workflow(&wf);
|
let report = linter.lint_workflow(&wf);
|
||||||
let unknown_count = report
|
let unknown_count = report
|
||||||
.errors
|
.errors
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,37 @@ use crate::slug::Slug;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct WorkflowFrontmatter {
|
pub struct WorkflowFrontmatter {
|
||||||
/// Workflows don't carry sources/created_at requirements in the
|
/// Workflows do not require timestamps in the MVP. Human-authored files
|
||||||
/// plan doc; only `updated_at` is required at the schema level.
|
/// may carry them; when absent the linter uses Unix epoch as a neutral
|
||||||
pub updated_at: DateTime<Utc>,
|
/// placeholder for the shared `Frontmatter` trait.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub auto_invoke: bool,
|
#[serde(default)]
|
||||||
|
pub model_invokation: bool,
|
||||||
|
#[serde(default = "default_user_invocable")]
|
||||||
pub user_invocable: bool,
|
pub user_invocable: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub requires: Vec<Slug>,
|
pub requires: Vec<Slug>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_user_invocable() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn epoch() -> DateTime<Utc> {
|
||||||
|
DateTime::<Utc>::from_timestamp(0, 0).expect("Unix epoch timestamp is valid")
|
||||||
|
}
|
||||||
|
|
||||||
impl Frontmatter for WorkflowFrontmatter {
|
impl Frontmatter for WorkflowFrontmatter {
|
||||||
const BODY_LIMIT: usize = 8000;
|
const BODY_LIMIT: usize = 8000;
|
||||||
|
|
||||||
fn created_at(&self) -> DateTime<Utc> {
|
fn created_at(&self) -> DateTime<Utc> {
|
||||||
self.created_at.unwrap_or(self.updated_at)
|
self.created_at.or(self.updated_at).unwrap_or_else(epoch)
|
||||||
}
|
}
|
||||||
fn updated_at(&self) -> DateTime<Utc> {
|
fn updated_at(&self) -> DateTime<Utc> {
|
||||||
self.updated_at
|
self.updated_at.unwrap_or_else(epoch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
/// Build deny rules that strip Write permission from `<workspace>/memory/`
|
/// Build deny rules that strip Write permission from `<workspace>/memory/`
|
||||||
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
|
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
|
||||||
/// at Read for the generic tools.
|
/// at Read for the generic tools, including `memory/workflow/`.
|
||||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||||
vec![
|
vec![
|
||||||
deny_write(layout.memory_dir().as_path()),
|
deny_write(layout.memory_dir().as_path()),
|
||||||
|
|
|
||||||
313
crates/memory/src/workflow.rs
Normal file
313
crates/memory/src/workflow.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
//! Workflow loader and registry.
|
||||||
|
//!
|
||||||
|
//! Workflows live under `<workspace>/.insomnia/memory/workflow/<slug>.md`.
|
||||||
|
//! They are human-authored Markdown documents with YAML frontmatter. The loader
|
||||||
|
//! is intentionally strict about malformed records because Pod startup should
|
||||||
|
//! fail rather than silently ignoring a broken procedural instruction.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::error::LintError;
|
||||||
|
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
|
||||||
|
use crate::slug::Slug;
|
||||||
|
use crate::workspace::WorkspaceLayout;
|
||||||
|
|
||||||
|
/// Hard cap on Workflow descriptions that are advertised resident.
|
||||||
|
/// Mirrors agent-skills and resident Knowledge descriptions.
|
||||||
|
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkflowRecord {
|
||||||
|
pub slug: Slug,
|
||||||
|
pub description: String,
|
||||||
|
pub model_invokation: bool,
|
||||||
|
pub user_invocable: bool,
|
||||||
|
pub requires: Vec<Slug>,
|
||||||
|
/// Markdown body after the closing frontmatter delimiter.
|
||||||
|
pub body: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct WorkflowRegistry {
|
||||||
|
records: BTreeMap<Slug, WorkflowRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkflowRegistry {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.records.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.records.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
|
||||||
|
self.records.get(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
|
||||||
|
self.records.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resident_entries(&self) -> Vec<ResidentWorkflowEntry> {
|
||||||
|
self.records
|
||||||
|
.values()
|
||||||
|
.filter(|record| record.model_invokation)
|
||||||
|
.map(|record| ResidentWorkflowEntry {
|
||||||
|
slug: record.slug.to_string(),
|
||||||
|
description: record.description.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_user_invocable(&self, prefix: &str) -> Vec<String> {
|
||||||
|
self.records
|
||||||
|
.values()
|
||||||
|
.filter(|record| record.user_invocable && record.slug.as_str().starts_with(prefix))
|
||||||
|
.map(|record| record.slug.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ResidentWorkflowEntry {
|
||||||
|
pub slug: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum WorkflowLoadError {
|
||||||
|
#[error("failed to read workflow directory {}: {source}", .dir.display())]
|
||||||
|
ReadDir { dir: PathBuf, source: io::Error },
|
||||||
|
#[error("failed to read workflow file {}: {source}", .path.display())]
|
||||||
|
ReadFile { path: PathBuf, source: io::Error },
|
||||||
|
#[error("invalid workflow file name {}: {source}", .path.display())]
|
||||||
|
InvalidSlug { path: PathBuf, source: LintError },
|
||||||
|
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
|
||||||
|
Frontmatter { path: PathBuf, source: LintError },
|
||||||
|
#[error(
|
||||||
|
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
|
||||||
|
.path.display()
|
||||||
|
)]
|
||||||
|
DescriptionTooLong {
|
||||||
|
path: PathBuf,
|
||||||
|
actual: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
|
||||||
|
let dir = layout.workflow_dir();
|
||||||
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return Ok(WorkflowRegistry::empty());
|
||||||
|
}
|
||||||
|
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|source| WorkflowLoadError::ReadDir {
|
||||||
|
dir: dir.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths.sort();
|
||||||
|
|
||||||
|
let mut records = BTreeMap::new();
|
||||||
|
for path in paths {
|
||||||
|
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let slug =
|
||||||
|
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if records.contains_key(&slug) {
|
||||||
|
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&path).map_err(|source| WorkflowLoadError::ReadFile {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let (yaml, body) =
|
||||||
|
split_frontmatter(&raw).map_err(|source| WorkflowLoadError::Frontmatter {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
warn_unknown_workflow_fields(&path, yaml);
|
||||||
|
let frontmatter: WorkflowFrontmatter =
|
||||||
|
serde_yaml::from_str(yaml).map_err(|err| WorkflowLoadError::Frontmatter {
|
||||||
|
path: path.clone(),
|
||||||
|
source: map_serde_workflow_error(err),
|
||||||
|
})?;
|
||||||
|
if frontmatter.model_invokation
|
||||||
|
&& frontmatter.description.chars().count() > WORKFLOW_DESCRIPTION_HARD_CAP
|
||||||
|
{
|
||||||
|
return Err(WorkflowLoadError::DescriptionTooLong {
|
||||||
|
path,
|
||||||
|
actual: frontmatter.description.chars().count(),
|
||||||
|
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = WorkflowRecord {
|
||||||
|
slug: slug.clone(),
|
||||||
|
description: frontmatter.description,
|
||||||
|
model_invokation: frontmatter.model_invokation,
|
||||||
|
user_invocable: frontmatter.user_invocable,
|
||||||
|
requires: frontmatter.requires,
|
||||||
|
body: body.to_string(),
|
||||||
|
path: path.clone(),
|
||||||
|
};
|
||||||
|
records.insert(slug.clone(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WorkflowRegistry { records })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_unknown_workflow_fields(path: &Path, yaml: &str) {
|
||||||
|
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(map) = value.as_mapping() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for key in map.keys().filter_map(|k| k.as_str()) {
|
||||||
|
if !matches!(
|
||||||
|
key,
|
||||||
|
"description"
|
||||||
|
| "model_invokation"
|
||||||
|
| "user_invocable"
|
||||||
|
| "requires"
|
||||||
|
| "created_at"
|
||||||
|
| "updated_at"
|
||||||
|
) {
|
||||||
|
warn!(path = %path.display(), field = key, "unknown workflow frontmatter field ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_serde_workflow_error(err: serde_yaml::Error) -> LintError {
|
||||||
|
let msg = err.to_string();
|
||||||
|
if let Some(field) = parse_missing_field(&msg) {
|
||||||
|
return LintError::MissingField(field);
|
||||||
|
}
|
||||||
|
LintError::MalformedFrontmatter(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||||
|
let needle = "missing field `";
|
||||||
|
let start = msg.find(needle)? + needle.len();
|
||||||
|
let end = msg[start..].find('`')? + start;
|
||||||
|
match &msg[start..end] {
|
||||||
|
"description" => Some("description"),
|
||||||
|
"model_invokation" => Some("model_invokation"),
|
||||||
|
"user_invocable" => Some("user_invocable"),
|
||||||
|
"requires" => Some("requires"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".insomnia/memory/workflow")).unwrap();
|
||||||
|
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||||
|
(dir, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_workflow(root: &Path, slug: &str, frontmatter: &str, body: &str) {
|
||||||
|
let path = root
|
||||||
|
.join(".insomnia/memory/workflow")
|
||||||
|
.join(format!("{slug}.md"));
|
||||||
|
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_directory_loads_empty_registry() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||||
|
let got = load_workflows(&layout).unwrap();
|
||||||
|
assert!(got.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_valid_workflow_with_default_flags() {
|
||||||
|
let (dir, layout) = setup();
|
||||||
|
write_workflow(dir.path(), "do-thing", "description: Do thing", "Step 1");
|
||||||
|
let got = load_workflows(&layout).unwrap();
|
||||||
|
let slug = Slug::parse("do-thing").unwrap();
|
||||||
|
let record = got.get(&slug).unwrap();
|
||||||
|
assert_eq!(record.description, "Do thing");
|
||||||
|
assert!(!record.model_invokation);
|
||||||
|
assert!(record.user_invocable);
|
||||||
|
assert!(record.requires.is_empty());
|
||||||
|
assert_eq!(record.body, "Step 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_invokation_uses_typo_field() {
|
||||||
|
let (dir, layout) = setup();
|
||||||
|
write_workflow(
|
||||||
|
dir.path(),
|
||||||
|
"auto",
|
||||||
|
"description: Auto\nmodel_invokation: true\nuser_invocable: false",
|
||||||
|
"Body",
|
||||||
|
);
|
||||||
|
let got = load_workflows(&layout).unwrap();
|
||||||
|
assert_eq!(got.resident_entries()[0].slug, "auto");
|
||||||
|
assert!(got.list_user_invocable("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_filename_is_hard_error() {
|
||||||
|
let (dir, layout) = setup();
|
||||||
|
write_workflow(dir.path(), "Bad", "description: Bad", "Body");
|
||||||
|
let err = load_workflows(&layout).unwrap_err();
|
||||||
|
assert!(matches!(err, WorkflowLoadError::InvalidSlug { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_description_is_hard_error() {
|
||||||
|
let (dir, layout) = setup();
|
||||||
|
write_workflow(dir.path(), "bad", "model_invokation: false", "Body");
|
||||||
|
let err = load_workflows(&layout).unwrap_err();
|
||||||
|
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resident_description_cap_is_enforced() {
|
||||||
|
let (dir, layout) = setup();
|
||||||
|
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||||
|
write_workflow(
|
||||||
|
dir.path(),
|
||||||
|
"bad",
|
||||||
|
&format!("description: {desc}\nmodel_invokation: true"),
|
||||||
|
"Body",
|
||||||
|
);
|
||||||
|
let err = load_workflows(&layout).unwrap_err();
|
||||||
|
assert!(matches!(err, WorkflowLoadError::DescriptionTooLong { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -263,11 +263,7 @@ impl PodController {
|
||||||
// query — keep a clone for the FS view we attach below,
|
// query — keep a clone for the FS view we attach below,
|
||||||
// since the tools consume `fs` itself.
|
// since the tools consume `fs` itself.
|
||||||
fs_for_view = fs.clone();
|
fs_for_view = fs.clone();
|
||||||
worker.register_tools(tools::builtin_tools(
|
worker.register_tools(tools::builtin_tools(fs, tracker.clone(), bash_output_dir));
|
||||||
fs,
|
|
||||||
tracker.clone(),
|
|
||||||
bash_output_dir,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Memory subsystem opt-in. When `[memory]` is present in
|
// Memory subsystem opt-in. When `[memory]` is present in
|
||||||
// the manifest, register the memory-specific Read/Write/Edit
|
// the manifest, register the memory-specific Read/Write/Edit
|
||||||
|
|
@ -321,6 +317,12 @@ impl PodController {
|
||||||
shared_state.update_history(pod.worker().history().to_vec());
|
shared_state.update_history(pod.worker().history().to_vec());
|
||||||
shared_state.set_user_segments(pod.user_segments().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_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_manifest(&manifest_toml).await?;
|
||||||
runtime_dir.write_status(&shared_state).await?;
|
runtime_dir.write_status(&shared_state).await?;
|
||||||
runtime_dir.write_history(&shared_state).await?;
|
runtime_dir.write_history(&shared_state).await?;
|
||||||
|
|
@ -365,6 +367,14 @@ impl PodController {
|
||||||
});
|
});
|
||||||
continue;
|
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
|
// Broadcast the accepted user message so every
|
||||||
// subscriber (including the submitter) can
|
// subscriber (including the submitter) can
|
||||||
// render the turn header + user line from a
|
// render the turn header + user line from a
|
||||||
|
|
@ -374,7 +384,6 @@ impl PodController {
|
||||||
let _ = event_tx.send(Event::UserMessage {
|
let _ = event_tx.send(Event::UserMessage {
|
||||||
segments: input.clone(),
|
segments: input.clone(),
|
||||||
});
|
});
|
||||||
let was_paused = status_before == PodStatus::Paused;
|
|
||||||
shared_state.set_status(PodStatus::Running);
|
shared_state.set_status(PodStatus::Running);
|
||||||
let _ = runtime_dir.write_status(&shared_state).await;
|
let _ = runtime_dir.write_status(&shared_state).await;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -647,6 +647,7 @@ permission = "write"
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_knowledge: None,
|
resident_knowledge: None,
|
||||||
|
resident_workflows: None,
|
||||||
prompts: &catalog,
|
prompts: &catalog,
|
||||||
};
|
};
|
||||||
let rendered = tmpl.render(&ctx).unwrap();
|
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,
|
is_dir: c.is_dir,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
// Knowledge / Workflow resolvers are not wired
|
protocol::CompletionKind::Knowledge => Vec::new(),
|
||||||
// up yet — reply empty so the TUI sees a
|
protocol::CompletionKind::Workflow => handle
|
||||||
// consistent shape regardless of kind.
|
.shared_state
|
||||||
protocol::CompletionKind::Knowledge
|
.list_workflow_completions(&prefix)
|
||||||
| protocol::CompletionKind::Workflow => Vec::new(),
|
.into_iter()
|
||||||
|
.map(|c| protocol::CompletionEntry {
|
||||||
|
value: c.slug,
|
||||||
|
is_dir: false,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
if writer
|
if writer
|
||||||
.write(&Event::Completions { kind, entries })
|
.write(&Event::Completions { kind, entries })
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod prompt;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
mod factory;
|
mod factory;
|
||||||
mod interrupt_and_run;
|
mod interrupt_and_run;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ use crate::prompt::loader::PromptLoader;
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
|
use crate::workflow::WorkflowResolveError;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreRequestAction;
|
use llm_worker::interceptor::PreRequestAction;
|
||||||
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||||
|
|
@ -130,6 +131,12 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// [`Self::from_manifest`], or defaults to the builtin pack when a
|
/// [`Self::from_manifest`], or defaults to the builtin pack when a
|
||||||
/// Pod is constructed through lower-level paths that have no loader.
|
/// Pod is constructed through lower-level paths that have no loader.
|
||||||
prompts: Arc<PromptCatalog>,
|
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
|
/// When true (default), the system-prompt assembler walks
|
||||||
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
|
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
|
||||||
/// section listing records with `model_invokation: true`.
|
/// section listing records with `model_invokation: true`.
|
||||||
|
|
@ -206,6 +213,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
scope_allocation: None,
|
scope_allocation: None,
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts,
|
prompts,
|
||||||
|
workflow_registry: memory::WorkflowRegistry::empty(),
|
||||||
|
memory_layout: None,
|
||||||
inject_resident_knowledge: true,
|
inject_resident_knowledge: true,
|
||||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
|
|
@ -606,23 +615,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
// Owned `Vec` lives for the duration of `render` below; the
|
// Owned `Vec` lives for the duration of `render` below; the
|
||||||
// context borrows a slice into it.
|
// context borrows a slice into it.
|
||||||
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
|
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
|
||||||
self.manifest
|
self.memory_layout
|
||||||
.memory
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|mem| {
|
.map(memory::collect_resident_knowledge)
|
||||||
let layout = memory::WorkspaceLayout::resolve(mem, &self.pwd);
|
|
||||||
memory::collect_resident_knowledge(&layout)
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
|
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)
|
Some(&resident)
|
||||||
} else {
|
} else {
|
||||||
None
|
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 scope_snapshot = self.scope.snapshot();
|
let scope_snapshot = self.scope.snapshot();
|
||||||
let ctx = SystemPromptContext {
|
let ctx = SystemPromptContext {
|
||||||
now: chrono::Utc::now(),
|
now: chrono::Utc::now(),
|
||||||
|
|
@ -631,6 +648,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
tool_names,
|
tool_names,
|
||||||
agents_md: agents_md_read.body,
|
agents_md: agents_md_read.body,
|
||||||
resident_knowledge: resident_slice,
|
resident_knowledge: resident_slice,
|
||||||
|
resident_workflows: resident_workflow_slice,
|
||||||
prompts: &self.prompts,
|
prompts: &self.prompts,
|
||||||
};
|
};
|
||||||
let rendered = template
|
let rendered = template
|
||||||
|
|
@ -678,11 +696,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.await?;
|
.await?;
|
||||||
self.user_segments.push(input.clone());
|
self.user_segments.push(input.clone());
|
||||||
|
|
||||||
// Resolve `@<path>` refs to system messages stashed for the
|
// Resolve `@<path>` refs and `/<slug>` workflow invocations to
|
||||||
// PodInterceptor to attach right after the user message. Failures
|
// system messages stashed for the PodInterceptor to attach right
|
||||||
// surface as user-facing Alerts and the placeholder remains in
|
// after the user message. File failures are non-fatal alerts; explicit
|
||||||
// the flattened text so the LLM sees the unresolved intent.
|
// workflow invocation failures abort before the Worker sees the turn.
|
||||||
let attachments = self.resolve_file_refs(&input);
|
let mut attachments = self.resolve_file_refs(&input);
|
||||||
|
attachments.extend(self.resolve_workflow_invocations(&input)?);
|
||||||
if !attachments.is_empty() {
|
if !attachments.is_empty() {
|
||||||
*self
|
*self
|
||||||
.pending_attachments
|
.pending_attachments
|
||||||
|
|
@ -732,6 +751,63 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
out
|
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
|
/// Flatten a typed segment list into the single string the Worker
|
||||||
/// receives as the user message, and emit user-facing alerts for
|
/// receives as the user message, and emit user-facing alerts for
|
||||||
/// segments that fall through to placeholder (knowledge / workflow
|
/// segments that fall through to placeholder (knowledge / workflow
|
||||||
|
|
@ -753,16 +829,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Segment::WorkflowInvoke { slug } => {
|
Segment::WorkflowInvoke { .. } => {}
|
||||||
self.alert(
|
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::Pod,
|
|
||||||
format!(
|
|
||||||
"workflow /{slug} cannot be resolved \
|
|
||||||
(resolver not yet implemented); passed to LLM as placeholder"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Segment::Unknown => {
|
Segment::Unknown => {
|
||||||
self.alert(
|
self.alert(
|
||||||
AlertLevel::Warn,
|
AlertLevel::Warn,
|
||||||
|
|
@ -1701,7 +1768,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||||
worker.register_tool(memory::tool::edit_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::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 tidy = consolidate::collect_tidy_hints(&layout);
|
||||||
let candidates = consolidate::KnowledgeCandidateReport::empty();
|
let candidates = consolidate::KnowledgeCandidateReport::empty();
|
||||||
|
|
@ -1858,6 +1928,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
workflow_registry: common.workflow_registry,
|
||||||
|
memory_layout: common.memory_layout,
|
||||||
inject_resident_knowledge: true,
|
inject_resident_knowledge: true,
|
||||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
|
|
@ -1916,6 +1988,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: Some(callback_socket),
|
callback_socket: Some(callback_socket),
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
workflow_registry: common.workflow_registry,
|
||||||
|
memory_layout: common.memory_layout,
|
||||||
inject_resident_knowledge: true,
|
inject_resident_knowledge: true,
|
||||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
|
|
@ -2026,6 +2100,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
workflow_registry: common.workflow_registry,
|
||||||
|
memory_layout: common.memory_layout,
|
||||||
inject_resident_knowledge: true,
|
inject_resident_knowledge: true,
|
||||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
|
||||||
|
|
@ -2233,6 +2309,12 @@ pub enum PodError {
|
||||||
#[error("memory Phase 2 lock acquisition failed: {0}")]
|
#[error("memory Phase 2 lock acquisition failed: {0}")]
|
||||||
ConsolidationLock(#[source] memory::consolidate::LockError),
|
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")]
|
#[error("session {session_id} has no entries to restore")]
|
||||||
SessionEmpty { session_id: SessionId },
|
SessionEmpty { session_id: SessionId },
|
||||||
}
|
}
|
||||||
|
|
@ -2246,6 +2328,8 @@ struct PodCommon {
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
client: Box<dyn LlmClient>,
|
client: Box<dyn LlmClient>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
|
workflow_registry: memory::WorkflowRegistry,
|
||||||
|
memory_layout: Option<memory::WorkspaceLayout>,
|
||||||
system_prompt_template: Option<SystemPromptTemplate>,
|
system_prompt_template: Option<SystemPromptTemplate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2272,6 +2356,14 @@ fn prepare_pod_common(
|
||||||
|
|
||||||
let client = provider::build_client(&manifest.model)?;
|
let client = provider::build_client(&manifest.model)?;
|
||||||
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
|
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 {
|
let system_prompt_template = if parse_template {
|
||||||
Some(
|
Some(
|
||||||
|
|
@ -2287,6 +2379,8 @@ fn prepare_pod_common(
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
prompts,
|
prompts,
|
||||||
|
workflow_registry,
|
||||||
|
memory_layout,
|
||||||
system_prompt_template,
|
system_prompt_template,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ pub enum PodPrompt {
|
||||||
/// AGENTS.md section when memory is enabled and at least one
|
/// AGENTS.md section when memory is enabled and at least one
|
||||||
/// `knowledge/*` record advertises `model_invokation: true`.
|
/// `knowledge/*` record advertises `model_invokation: true`.
|
||||||
ResidentKnowledgeSection,
|
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 {
|
impl PodPrompt {
|
||||||
|
|
@ -91,6 +95,7 @@ impl PodPrompt {
|
||||||
Self::WorkingBoundariesSection => "working_boundaries_section",
|
Self::WorkingBoundariesSection => "working_boundaries_section",
|
||||||
Self::AgentsMdSection => "agents_md_section",
|
Self::AgentsMdSection => "agents_md_section",
|
||||||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
||||||
|
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,6 +110,7 @@ impl PodPrompt {
|
||||||
PodPrompt::WorkingBoundariesSection,
|
PodPrompt::WorkingBoundariesSection,
|
||||||
PodPrompt::AgentsMdSection,
|
PodPrompt::AgentsMdSection,
|
||||||
PodPrompt::ResidentKnowledgeSection,
|
PodPrompt::ResidentKnowledgeSection,
|
||||||
|
PodPrompt::ResidentWorkflowsSection,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const KEYS: &'static [&'static str] = &[
|
pub const KEYS: &'static [&'static str] = &[
|
||||||
|
|
@ -115,6 +121,7 @@ impl PodPrompt {
|
||||||
"working_boundaries_section",
|
"working_boundaries_section",
|
||||||
"agents_md_section",
|
"agents_md_section",
|
||||||
"resident_knowledge_section",
|
"resident_knowledge_section",
|
||||||
|
"resident_workflows_section",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,6 +337,15 @@ impl PromptCatalog {
|
||||||
single("entries", entries),
|
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 {
|
fn single(key: &'static str, value: &str) -> Value {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{DateTime, SecondsFormat, Utc};
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use manifest::Scope;
|
use manifest::Scope;
|
||||||
use memory::ResidentKnowledgeEntry;
|
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry};
|
||||||
use minijinja::value::Value;
|
use minijinja::value::Value;
|
||||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
@ -122,6 +122,7 @@ impl SystemPromptTemplate {
|
||||||
ctx.scope,
|
ctx.scope,
|
||||||
ctx.agents_md.as_deref(),
|
ctx.agents_md.as_deref(),
|
||||||
ctx.resident_knowledge,
|
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
|
/// section entirely (memory disabled, or a Phase 2 worker that opts
|
||||||
/// out); `Some(&[])` also yields no section.
|
/// out); `Some(&[])` also yields no section.
|
||||||
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
|
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.
|
/// Catalog used to render the fixed trailing section headers.
|
||||||
/// Passed by reference so callers do not give up ownership across
|
/// Passed by reference so callers do not give up ownership across
|
||||||
/// the short-lived render borrow.
|
/// the short-lived render borrow.
|
||||||
|
|
@ -201,6 +206,7 @@ pub fn append_trailing_section(
|
||||||
scope: &Scope,
|
scope: &Scope,
|
||||||
agents_md: Option<&str>,
|
agents_md: Option<&str>,
|
||||||
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
||||||
|
resident_workflows: Option<&[ResidentWorkflowEntry]>,
|
||||||
) -> Result<String, SystemPromptError> {
|
) -> Result<String, SystemPromptError> {
|
||||||
let mut out = String::with_capacity(body.len() + 256);
|
let mut out = String::with_capacity(body.len() + 256);
|
||||||
out.push_str(body);
|
out.push_str(body);
|
||||||
|
|
@ -227,6 +233,15 @@ pub fn append_trailing_section(
|
||||||
out.push('\n');
|
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
|
// Canonicalise the tail so the emitted prompt has a single form
|
||||||
// regardless of how individual templates chose to end.
|
// regardless of how individual templates chose to end.
|
||||||
while out.ends_with('\n') || out.ends_with(' ') {
|
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
|
/// `- <slug>: <description>` per line. Description newlines are folded
|
||||||
/// to spaces so a single entry stays on one row in the rendered prompt.
|
/// to spaces so a single entry stays on one row in the rendered prompt.
|
||||||
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
|
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();
|
let mut out = String::new();
|
||||||
for (i, e) in entries.iter().enumerate() {
|
for (i, (slug, description)) in entries.enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out.push_str("- ");
|
out.push_str("- ");
|
||||||
out.push_str(&e.slug);
|
out.push_str(slug);
|
||||||
out.push_str(": ");
|
out.push_str(": ");
|
||||||
for ch in e.description.chars() {
|
for ch in description.chars() {
|
||||||
if ch == '\n' || ch == '\r' {
|
if ch == '\n' || ch == '\r' {
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -300,6 +331,7 @@ mod tests {
|
||||||
tool_names: tools,
|
tool_names: tools,
|
||||||
agents_md,
|
agents_md,
|
||||||
resident_knowledge: None,
|
resident_knowledge: None,
|
||||||
|
resident_workflows: None,
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +348,7 @@ mod tests {
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_knowledge: Some(resident),
|
resident_knowledge: Some(resident),
|
||||||
|
resident_workflows: None,
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ use session_store::SessionId;
|
||||||
|
|
||||||
use crate::fs_view::PodFsView;
|
use crate::fs_view::PodFsView;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkflowCandidate {
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared state between PodController and runtime directory.
|
/// Shared state between PodController and runtime directory.
|
||||||
///
|
///
|
||||||
/// Controller updates this in-memory; RuntimeDir writes it to disk.
|
/// 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`
|
/// (only relevant for unit tests that build a `PodSharedState`
|
||||||
/// directly without spinning up a controller).
|
/// directly without spinning up a controller).
|
||||||
fs_view: OnceLock<PodFsView>,
|
fs_view: OnceLock<PodFsView>,
|
||||||
|
workflows: OnceLock<Vec<WorkflowCandidate>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -57,6 +63,7 @@ impl PodSharedState {
|
||||||
history: RwLock::new(Vec::new()),
|
history: RwLock::new(Vec::new()),
|
||||||
user_segments: RwLock::new(Vec::new()),
|
user_segments: RwLock::new(Vec::new()),
|
||||||
fs_view: OnceLock::new(),
|
fs_view: OnceLock::new(),
|
||||||
|
workflows: OnceLock::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +79,23 @@ impl PodSharedState {
|
||||||
self.fs_view.get()
|
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>> {
|
pub fn user_segments(&self) -> Vec<Vec<Segment>> {
|
||||||
self.user_segments
|
self.user_segments
|
||||||
.read()
|
.read()
|
||||||
|
|
|
||||||
197
crates/pod/src/workflow/mod.rs
Normal file
197
crates/pod/src/workflow/mod.rs
Normal file
|
|
@ -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 { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -412,6 +412,7 @@ pub enum ErrorCode {
|
||||||
NotPaused,
|
NotPaused,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
ToolError,
|
ToolError,
|
||||||
|
InvalidRequest,
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,12 @@ The following knowledge records are advertised resident. Use the KnowledgeQuery
|
||||||
|
|
||||||
{{ entries }}\
|
{{ entries }}\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
resident_workflows_section = """\
|
||||||
|
---
|
||||||
|
## Resident workflows
|
||||||
|
|
||||||
|
The following workflows are advertised resident. When a user request matches one, follow its procedure as authoritative instead of improvising. User-invocable workflows can additionally be triggered by the user typing /<slug>; you cannot invoke any of them yourself.
|
||||||
|
|
||||||
|
{{ entries }}\
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
# Workflow 実装
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
`docs/plan/workflow.md` で決まった「制約付きの強制的な作業フロー」を `/<slug>` で呼び出せるようにする。Knowledge (`#<slug>`) を依存として inject できる経路を持つことで、procedural な能力を再利用可能な単位に固定する。
|
|
||||||
|
|
||||||
memory 機構(`docs/plan/memory.md`)からは独立してスタートできる: Workflow は人間が書く / consolidation の offer 経由でしか作られず、自動書き込み禁止のため Phase 2 の前提に依存しない。Knowledge resolver は `requires` の inject 経路として相互依存する。
|
|
||||||
|
|
||||||
agent-skills (agentskills.io 形式) は本チケットの ingest 経路を再利用して Workflow として読み込む側になる(`tickets/agent-skills.md` 参照)。
|
|
||||||
|
|
||||||
## 決定事項の参照
|
|
||||||
|
|
||||||
詳細は `docs/plan/workflow.md` 参照。要点のみ:
|
|
||||||
|
|
||||||
- 呼び出し: `/<slug>`、フラットな名前空間、kebab-case
|
|
||||||
- 配置: `<workspace_root>/.insomnia/memory/workflow/<slug>.md`(ファイル名 = slug、frontmatter に `name` を持たない)
|
|
||||||
- frontmatter: `description` / `model_invokation` (default OFF) / `user_invocable` (default ON) / `requires: [knowledge-slug, ...]`
|
|
||||||
- 実行: `requires` の Knowledge 本文を context に inject してから Workflow 本文を実行
|
|
||||||
- 自動書き込み禁止(consolidation の write tool schema に `workflow` カテゴリを含めないことで構造的に担保。Linter で人間にも見える形で再保証)
|
|
||||||
|
|
||||||
## 方針
|
|
||||||
|
|
||||||
### MVP スコープ
|
|
||||||
|
|
||||||
1. **Workflow loader / 検証**
|
|
||||||
- `<workspace_root>/.insomnia/memory/workflow/*.md` を走査
|
|
||||||
- frontmatter を仕様通り検証。必須欠落・型不一致・slug とファイル名の不一致は hard error(Pod 起動失敗)
|
|
||||||
- 未知フィールドは `tracing::warn!` して無視(既存 manifest と同方針)
|
|
||||||
- 重複 slug は最初に見つかったものを採用 + warn(後述の skill ingest が乗ると衝突解決ルールが追加で必要になる)
|
|
||||||
|
|
||||||
2. **`/<slug>` 呼び出し経路**
|
|
||||||
- `Segment::WorkflowInvoke { slug }` を Pod 側で resolve
|
|
||||||
- 解決失敗(slug 未登録 / `user_invocable: false`)は `ToolError` 相当でユーザーに返し、Worker には届かない
|
|
||||||
- `requires` の Knowledge 本文を Knowledge 検索ツールの slug 完全一致経路で取得し、Workflow 本文の前に context へ inject
|
|
||||||
- Workflow 本文は Markdown のままサブミット内容として扱う(DSL 化はしない)
|
|
||||||
|
|
||||||
3. **`model_invokation` 注入**
|
|
||||||
- `model_invokation: true` な Workflow の `description` を通常 Pod の system prompt に常駐注入する。Phase 2 prompt には入れない
|
|
||||||
- 予算は Knowledge の常駐注入(`memory.md` §retrieval 経路)と合算管理。description 上限は agentskills 準拠の 1024 chars に揃える
|
|
||||||
|
|
||||||
4. **Linter ルール**
|
|
||||||
- `memory/workflow/*.md` への write/edit は memory 専用 Tool 経由でのみ許可(汎用 Write/Edit は Scope deny)
|
|
||||||
- consolidation の write tool schema からは `workflow` カテゴリを除外(自動書き込み禁止の構造的担保)
|
|
||||||
- Workflow 自体の Linter は frontmatter 検証 + slug/ファイル名一致のみ。意味検証は将来検討
|
|
||||||
|
|
||||||
### 範囲外
|
|
||||||
|
|
||||||
- DSL 化や step 粒度の制約(Markdown 本文をそのまま実行)
|
|
||||||
- 中断・再開・トランザクション管理
|
|
||||||
- 品質検証フロー(mizchi empirical-prompt-tuning 相当、`docs/plan/workflow.md` §将来検討)
|
|
||||||
- LLM による Workflow 自律生成(offer までで留める方針は本チケットでは扱わず、consolidation 側の責務)
|
|
||||||
- Knowledge 検索ツール本体の実装(memory チケット側)。本チケットは slug 完全一致経路の利用者に留まる
|
|
||||||
|
|
||||||
## 完了条件
|
|
||||||
|
|
||||||
- `<workspace_root>/.insomnia/memory/workflow/*.md` をロードし、frontmatter 違反は Pod 起動エラーになる
|
|
||||||
- `/<slug>` を含む submit が `Segment::WorkflowInvoke` として送られ、Pod 側で `requires` Knowledge を inject した上で本文が実行される
|
|
||||||
- `model_invokation: true` の Workflow description が通常 Pod の system prompt に列挙される
|
|
||||||
- `user_invocable: false` の Workflow は `/<slug>` 補完候補から除外され、明示呼び出しもエラーになる
|
|
||||||
- 単体テストで frontmatter 検証の正常 / 異常系、`requires` 解決、フラグ別の挙動が verify される
|
|
||||||
|
|
||||||
## 実装順序
|
|
||||||
|
|
||||||
1. `manifest` または既存 memory クレートに `Workflow` 構造体と `WorkflowDirectoryLoader` を置く。frontmatter パースと検証のみでテスト完結
|
|
||||||
2. Pod に Workflow registry を持たせ、`model_invokation` description の system prompt 注入を組む
|
|
||||||
3. `Segment::WorkflowInvoke` の resolver を Pod 側に実装。Knowledge 検索ツールの slug 完全一致経路で `requires` を inject
|
|
||||||
4. 汎用 Write/Edit に対する `memory/workflow/` deny を Scope に追加、Linter 仕上げ
|
|
||||||
|
|
||||||
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
|
|
||||||
|
|
||||||
## 参照
|
|
||||||
|
|
||||||
- 設計: `docs/plan/workflow.md`
|
|
||||||
- Knowledge / `#<slug>` の retrieval: `docs/plan/memory.md` §retrieval 経路
|
|
||||||
- Submit segment: `tickets/submit-tui-completion.md`(`Atom::WorkflowInvoke`)、`tickets/session-log-segments.md`
|
|
||||||
- 後続: `tickets/agent-skills.md`(外部 SKILL を Workflow として ingest する経路)
|
|
||||||
Loading…
Reference in New Issue
Block a user