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:
@@ -817,6 +817,7 @@ pub enum SourceType {
|
||||
Recipe,
|
||||
Subrecipe,
|
||||
Agent,
|
||||
Project,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SourceType {
|
||||
@@ -827,6 +828,7 @@ impl std::fmt::Display for SourceType {
|
||||
SourceType::Recipe => write!(f, "recipe"),
|
||||
SourceType::Subrecipe => write!(f, "subrecipe"),
|
||||
SourceType::Agent => write!(f, "agent"),
|
||||
SourceType::Project => write!(f, "project"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -842,10 +844,11 @@ pub struct SourceEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
/// Absolute path to the source on disk. A directory for skills, a file for
|
||||
/// recipes and agents. Built-in skills use read-only synthetic
|
||||
/// `builtin://skills/<name>` paths.
|
||||
pub directory: String,
|
||||
/// Stable on-disk path identifying this source. Pass it back to
|
||||
/// update/delete/export to operate on this entry. Skills use the directory
|
||||
/// containing `SKILL.md`; projects use the project file path; built-in
|
||||
/// skills use `builtin://skills/<name>` synthetic paths.
|
||||
pub path: String,
|
||||
/// True when the source lives in the user's global sources directory; false
|
||||
/// when it lives inside a specific project.
|
||||
pub global: bool,
|
||||
@@ -853,6 +856,10 @@ pub struct SourceEntry {
|
||||
/// Only skills currently populate this; empty for other source types.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub supporting_files: Vec<String>,
|
||||
/// Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,
|
||||
/// preferredProvider for projects). Stored in the frontmatter.
|
||||
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
|
||||
pub properties: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SourceEntry {
|
||||
@@ -881,6 +888,14 @@ pub struct CreateSourceRequest {
|
||||
/// Absolute path to the project root. Required when `global` is false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
/// Project source ID. When set with `global: false`, the backend resolves
|
||||
/// the project's first working directory automatically. Takes precedence
|
||||
/// over `project_dir`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
/// Arbitrary key/value metadata.
|
||||
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
|
||||
pub properties: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
@@ -903,6 +918,10 @@ pub struct ListSourcesRequest {
|
||||
pub source_type: Option<SourceType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
/// When true, also scan the working directories of all known projects for
|
||||
/// project-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`).
|
||||
#[serde(default)]
|
||||
pub include_project_sources: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
@@ -922,6 +941,13 @@ pub struct UpdateSourceRequest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
/// When `Some`, replaces all stored properties on the source. When
|
||||
/// `None` (or omitted), the source's existing properties are
|
||||
/// preserved. Callers that don't model the full property bag (e.g.
|
||||
/// the skills editor, which only edits name/description/content)
|
||||
/// should omit this so per-skill metadata isn't silently erased.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
|
||||
@@ -1884,6 +1884,18 @@
|
||||
"null"
|
||||
],
|
||||
"description": "Absolute path to the project root. Required when `global` is false."
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project source ID. When set with `global: false`, the backend resolves\nthe project's first working directory automatically. Takes precedence\nover `project_dir`."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {},
|
||||
"description": "Arbitrary key/value metadata."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1904,7 +1916,8 @@
|
||||
"builtinSkill",
|
||||
"recipe",
|
||||
"subrecipe",
|
||||
"agent"
|
||||
"agent",
|
||||
"project"
|
||||
],
|
||||
"description": "The type of source entity."
|
||||
},
|
||||
@@ -1936,9 +1949,9 @@
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents. Built-in skills use read-only synthetic\n`builtin://skills/<name>` paths."
|
||||
"description": "Stable on-disk path identifying this source. Pass it back to\nupdate/delete/export to operate on this entry. Skills use the directory\ncontaining `SKILL.md`; projects use the project file path; built-in\nskills use `builtin://skills/<name>` synthetic paths."
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean",
|
||||
@@ -1950,6 +1963,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Paths (absolute) of additional files that live alongside the source.\nOnly skills currently populate this; empty for other source types."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {},
|
||||
"description": "Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,\npreferredProvider for projects). Stored in the frontmatter."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1957,7 +1975,7 @@
|
||||
"name",
|
||||
"description",
|
||||
"content",
|
||||
"directory",
|
||||
"path",
|
||||
"global"
|
||||
],
|
||||
"description": "A source discovered by Goose. Filesystem sources use an on-disk path;\nbuilt-in sources use a stable synthetic path. Sources may be either\n`global` (shared across all projects) or project-specific."
|
||||
@@ -1980,6 +1998,11 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeProjectSources": {
|
||||
"type": "boolean",
|
||||
"description": "When true, also scan the working directories of all known projects for\nproject-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`).",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"description": "List discovered sources.\n\nIf `type` is omitted or `skill`, this lists filesystem/plugin skills only.\nBoth global and project-scoped skills are included when `project_dir` is\nset. If `type` is `builtinSkill`, this lists shipped read-only built-in\nskills.",
|
||||
@@ -2019,6 +2042,14 @@
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"properties": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"additionalProperties": {},
|
||||
"description": "When `Some`, replaces all stored properties on the source. When\n`None` (or omitted), the source's existing properties are\npreserved. Callers that don't model the full property bag (e.g.\nthe skills editor, which only edits name/description/content)\nshould omit this so per-skill metadata isn't silently erased."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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
@@ -140,7 +140,7 @@ fn test_custom_list_builtin_skill_sources() {
|
||||
);
|
||||
assert_eq!(builtin.get("global"), Some(&serde_json::json!(true)));
|
||||
assert_eq!(
|
||||
builtin.get("directory"),
|
||||
builtin.get("path"),
|
||||
Some(&serde_json::json!("builtin://skills/goose-doc-guide"))
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user