feat: projects as backend sources with system prompt injection (#8739)
Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3150,7 +3150,7 @@ impl GooseAcpAgent {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_thread_metadata(
|
||||
pub(super) async fn update_thread_metadata(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
f: impl FnOnce(&mut crate::session::ThreadMetadata),
|
||||
|
||||
@@ -5,13 +5,26 @@ impl GooseAcpAgent {
|
||||
&self,
|
||||
req: CreateSourceRequest,
|
||||
) -> Result<CreateSourceResponse, agent_client_protocol::Error> {
|
||||
let project_dir = match (&req.project_id, &req.project_dir) {
|
||||
(Some(pid), _) if !req.global => {
|
||||
let dirs = crate::sources::project_working_dirs(pid);
|
||||
Some(dirs.into_iter().next().ok_or_else(|| {
|
||||
agent_client_protocol::Error::invalid_params().data(format!(
|
||||
"Project \"{pid}\" has no working directories configured"
|
||||
))
|
||||
})?)
|
||||
}
|
||||
(_, Some(pd)) => Some(pd.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let source = crate::sources::create_source(
|
||||
req.source_type,
|
||||
&req.name,
|
||||
&req.description,
|
||||
&req.content,
|
||||
req.global,
|
||||
req.project_dir.as_deref(),
|
||||
project_dir.as_deref(),
|
||||
req.properties,
|
||||
)?;
|
||||
Ok(CreateSourceResponse { source })
|
||||
}
|
||||
@@ -20,7 +33,11 @@ impl GooseAcpAgent {
|
||||
&self,
|
||||
req: ListSourcesRequest,
|
||||
) -> Result<ListSourcesResponse, agent_client_protocol::Error> {
|
||||
let sources = crate::sources::list_sources(req.source_type, req.project_dir.as_deref())?;
|
||||
let sources = crate::sources::list_sources(
|
||||
req.source_type,
|
||||
req.project_dir.as_deref(),
|
||||
req.include_project_sources,
|
||||
)?;
|
||||
Ok(ListSourcesResponse { sources })
|
||||
}
|
||||
|
||||
@@ -34,6 +51,7 @@ impl GooseAcpAgent {
|
||||
&req.name,
|
||||
&req.description,
|
||||
&req.content,
|
||||
req.properties,
|
||||
)?;
|
||||
Ok(UpdateSourceResponse { source })
|
||||
}
|
||||
|
||||
@@ -366,6 +366,24 @@ impl Agent {
|
||||
messages
|
||||
}
|
||||
|
||||
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
|
||||
let thread_id = session.thread_id.as_deref()?;
|
||||
let thread_mgr =
|
||||
crate::session::ThreadManager::new(self.config.session_manager.storage().clone());
|
||||
let thread = thread_mgr.get_thread(thread_id).await.ok()?;
|
||||
let project_id = thread.metadata.project_id.as_deref()?;
|
||||
let entry = crate::sources::read_project(project_id).ok()?;
|
||||
let mut parts = Vec::new();
|
||||
parts.push(format!("# Project: {}", entry.name));
|
||||
if !entry.description.is_empty() {
|
||||
parts.push(entry.description.clone());
|
||||
}
|
||||
if !entry.content.is_empty() {
|
||||
parts.push(entry.content.clone());
|
||||
}
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
|
||||
async fn prepare_reply_context(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -1251,6 +1269,11 @@ impl Agent {
|
||||
goose_mode,
|
||||
initial_messages,
|
||||
} = context;
|
||||
|
||||
if let Some(project_addendum) = self.load_project_instructions(&session).await {
|
||||
system_prompt = format!("{system_prompt}\n\n{project_addendum}");
|
||||
}
|
||||
|
||||
self.reset_retry_attempts().await;
|
||||
|
||||
let provider = self.provider().await?;
|
||||
|
||||
@@ -125,9 +125,10 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
|
||||
name: metadata.name,
|
||||
description,
|
||||
content: body,
|
||||
directory: path.to_string_lossy().into_owned(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
global: false,
|
||||
supporting_files: Vec::new(),
|
||||
properties: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,9 +172,10 @@ fn scan_recipes_from_dir(
|
||||
name,
|
||||
description: recipe.description.clone(),
|
||||
content: recipe.instructions.clone().unwrap_or_default(),
|
||||
directory: path.to_string_lossy().into_owned(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
global: false,
|
||||
supporting_files: Vec::new(),
|
||||
properties: std::collections::HashMap::new(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -598,9 +600,10 @@ impl SummonClient {
|
||||
name: sr.name.clone(),
|
||||
description,
|
||||
content: String::new(),
|
||||
directory: sr.path.clone(),
|
||||
path: sr.path.clone(),
|
||||
global: false,
|
||||
supporting_files: Vec::new(),
|
||||
properties: std::collections::HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1160,7 +1163,7 @@ impl SummonClient {
|
||||
}
|
||||
}
|
||||
|
||||
let recipe_file = load_local_recipe_file(&source.directory)
|
||||
let recipe_file = load_local_recipe_file(&source.path)
|
||||
.map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?;
|
||||
|
||||
let param_values: Vec<(String, String)> = params
|
||||
@@ -1193,10 +1196,10 @@ impl SummonClient {
|
||||
source: &SourceEntry,
|
||||
params: &DelegateParams,
|
||||
) -> Result<Recipe, String> {
|
||||
let agent_content = if source.directory.is_empty() {
|
||||
let agent_content = if source.path.is_empty() {
|
||||
return Err("Agent source has no path".to_string());
|
||||
} else {
|
||||
std::fs::read_to_string(&source.directory)
|
||||
std::fs::read_to_string(&source.path)
|
||||
.map_err(|e| format!("Failed to read agent file: {}", e))?
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ impl SkillsClient {
|
||||
s.source_type == SourceType::Skill || s.source_type == SourceType::BuiltinSkill
|
||||
})
|
||||
.collect();
|
||||
skills.sort_by(|a, b| (&a.name, &a.directory).cmp(&(&b.name, &b.directory)));
|
||||
skills.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path)));
|
||||
|
||||
if !skills.is_empty() {
|
||||
instructions.push_str(
|
||||
@@ -131,10 +131,10 @@ impl McpClientTrait for SkillsClient {
|
||||
);
|
||||
|
||||
if !skill.supporting_files.is_empty() {
|
||||
let skill_dir = Path::new(&skill.directory);
|
||||
let skill_dir = Path::new(&skill.path);
|
||||
output.push_str(&format!(
|
||||
"\n## Supporting Files\n\nSkill directory: {}\n\n",
|
||||
skill.directory
|
||||
skill.path
|
||||
));
|
||||
for file in &skill.supporting_files {
|
||||
if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) {
|
||||
@@ -157,7 +157,7 @@ impl McpClientTrait for SkillsClient {
|
||||
s.name == parent_skill_name
|
||||
&& matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill)
|
||||
}) {
|
||||
let skill_dir = PathBuf::from(&skill.directory);
|
||||
let skill_dir = PathBuf::from(&skill.path);
|
||||
let canonical_skill_dir = skill_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| skill_dir.clone());
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::sources::parse_frontmatter;
|
||||
use agent_client_protocol::Error;
|
||||
use goose_sdk::custom_requests::{SourceEntry, SourceType};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
@@ -23,6 +24,12 @@ pub struct SkillFrontmatter {
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Free-form bag for caller-defined fields. Per the agentskills.io spec
|
||||
/// (<https://agentskills.io/specification#frontmatter>), arbitrary
|
||||
/// metadata lives in this nested mapping so it doesn't collide with
|
||||
/// reserved frontmatter fields.
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Canonical writable location for global user skills: `~/.agents/skills`.
|
||||
@@ -169,9 +176,31 @@ pub(crate) fn infer_skill_name(dir: &Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_md(name: &str, description: &str, content: &str) -> String {
|
||||
pub(crate) fn build_skill_md(
|
||||
name: &str,
|
||||
description: &str,
|
||||
content: &str,
|
||||
metadata: &HashMap<String, Value>,
|
||||
) -> String {
|
||||
let safe_desc = description.replace('\'', "''");
|
||||
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
|
||||
let mut md = String::from("---\n");
|
||||
md.push_str(&format!("name: {}\n", name));
|
||||
md.push_str(&format!("description: '{}'\n", safe_desc));
|
||||
if !metadata.is_empty() {
|
||||
md.push_str("metadata:\n");
|
||||
// Use YAML for the nested metadata block. We render it with serde_yaml
|
||||
// and indent every line by two spaces so it nests under `metadata:`.
|
||||
let yaml = serde_yaml::to_string(metadata).unwrap_or_default();
|
||||
for line in yaml.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
md.push_str(" ");
|
||||
md.push_str(line);
|
||||
md.push('\n');
|
||||
}
|
||||
}
|
||||
md.push_str("---\n");
|
||||
if !content.is_empty() {
|
||||
md.push('\n');
|
||||
md.push_str(content);
|
||||
@@ -252,9 +281,10 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option<Sourc
|
||||
name,
|
||||
description: metadata.description,
|
||||
content: body,
|
||||
directory: path.to_string_lossy().into_owned(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
global,
|
||||
supporting_files: Vec::new(),
|
||||
properties: metadata.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,10 +399,10 @@ pub fn discover_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
|
||||
if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) {
|
||||
if !seen.contains(&source.name) {
|
||||
seen.insert(source.name.clone());
|
||||
let directory = format!("builtin://skills/{}", source.name);
|
||||
let path = format!("builtin://skills/{}", source.name);
|
||||
sources.push(SourceEntry {
|
||||
source_type: SourceType::BuiltinSkill,
|
||||
directory,
|
||||
path,
|
||||
..source
|
||||
});
|
||||
}
|
||||
|
||||
+714
-183
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user