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:
Douwe Osinga
2026-05-07 10:30:15 -04:00
committed by GitHub
parent bced4ea5ec
commit ff42171903
29 changed files with 1241 additions and 919 deletions
+30 -4
View File
@@ -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)]
+35 -4
View File
@@ -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": [
+1 -1
View File
@@ -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),
+20 -2
View File
@@ -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 })
}
+23
View File
@@ -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))?
};
+4 -4
View File
@@ -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());
+36 -6
View File
@@ -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
});
}
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"))
);
});