Skip to content

Commit 1eb4629

Browse files
sunilkumarvalmikiDouwe Osinga
authored andcommitted
feat: projects as backend sources with system prompt injection
Move projects from Tauri IPC commands to the ACP sources system, making them a backend-managed entity available to all clients (desktop, CLI, TUI). Backend: Add Project to SourceType, properties bag on SourceEntry, project storage in Paths::data_dir()/projects/, system prompt injection in agent reply, _goose/session/set_project ACP method, includeProjectSources flag for listing skills from project working directories. Frontend: Rewrite projects API from Tauri invoke to ACP ext methods, remove buildProjectSystemPrompt (backend handles it now), add project picker to CreateSkillDialog, show project badges on non-global skills. Delete ui/goose2/src-tauri/src/commands/projects.rs (508 lines). Signed-off-by: Douwe Osinga <douwe@squareup.com>
1 parent aa731a9 commit 1eb4629

31 files changed

Lines changed: 1142 additions & 823 deletions

crates/goose-acp/acp-meta.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@
110110
"requestType": "UnarchiveSessionRequest",
111111
"responseType": "EmptyResponse"
112112
},
113+
{
114+
"method": "_goose/session/set_project",
115+
"requestType": "SetSessionProjectRequest",
116+
"responseType": "EmptyResponse"
117+
},
113118
{
114119
"method": "_goose/sources/create",
115120
"requestType": "CreateSourceRequest",

crates/goose-acp/acp-schema.json

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,27 @@
795795
"x-side": "agent",
796796
"x-method": "_goose/session/unarchive"
797797
},
798+
"SetSessionProjectRequest": {
799+
"type": "object",
800+
"properties": {
801+
"sessionId": {
802+
"type": "string"
803+
},
804+
"projectId": {
805+
"type": [
806+
"string",
807+
"null"
808+
],
809+
"description": "The source name (kebab-case ID) of the project, or null to clear."
810+
}
811+
},
812+
"required": [
813+
"sessionId"
814+
],
815+
"description": "Set or clear the project associated with a session.",
816+
"x-side": "agent",
817+
"x-method": "_goose/session/set_project"
818+
},
798819
"CreateSourceRequest": {
799820
"type": "object",
800821
"properties": {
@@ -819,6 +840,11 @@
819840
"null"
820841
],
821842
"description": "Absolute path to the project root. Required when `global` is false."
843+
},
844+
"properties": {
845+
"type": "object",
846+
"additionalProperties": {},
847+
"description": "Arbitrary key/value metadata."
822848
}
823849
},
824850
"required": [
@@ -835,7 +861,8 @@
835861
"SourceType": {
836862
"type": "string",
837863
"enum": [
838-
"skill"
864+
"skill",
865+
"project"
839866
],
840867
"description": "The type of source entity."
841868
},
@@ -874,6 +901,11 @@
874901
"global": {
875902
"type": "boolean",
876903
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
904+
},
905+
"properties": {
906+
"type": "object",
907+
"additionalProperties": {},
908+
"description": "Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,\npreferredProvider for projects). Stored in the frontmatter."
877909
}
878910
},
879911
"required": [
@@ -949,6 +981,11 @@
949981
"string",
950982
"null"
951983
]
984+
},
985+
"properties": {
986+
"type": "object",
987+
"additionalProperties": {},
988+
"description": "Arbitrary key/value metadata. Replaces all existing properties."
952989
}
953990
},
954991
"required": [
@@ -1621,6 +1658,15 @@
16211658
"description": "Params for _goose/session/unarchive",
16221659
"title": "UnarchiveSessionRequest"
16231660
},
1661+
{
1662+
"allOf": [
1663+
{
1664+
"$ref": "#/$defs/SetSessionProjectRequest"
1665+
}
1666+
],
1667+
"description": "Params for _goose/session/set_project",
1668+
"title": "SetSessionProjectRequest"
1669+
},
16241670
{
16251671
"allOf": [
16261672
{

crates/goose-acp/src/server.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,9 +1512,17 @@ impl GooseAcpAgent {
15121512
.and_then(|v| v.as_str())
15131513
.map(|s| s.to_string());
15141514

1515+
let requested_project = args
1516+
.meta
1517+
.as_ref()
1518+
.and_then(|m| m.get("project_id"))
1519+
.and_then(|v| v.as_str())
1520+
.map(|s| s.to_string());
1521+
15151522
// Create the Thread — this IS the ACP session from the client's perspective.
15161523
let thread_metadata = goose::session::ThreadMetadata {
15171524
provider_id: requested_provider.clone(),
1525+
project_id: requested_project,
15181526
mode: Some(self.goose_mode.to_string()),
15191527
..Default::default()
15201528
};
@@ -3115,18 +3123,47 @@ impl GooseAcpAgent {
31153123
Ok(EmptyResponse {})
31163124
}
31173125

3126+
#[custom_method(SetSessionProjectRequest)]
3127+
async fn on_set_session_project(
3128+
&self,
3129+
req: SetSessionProjectRequest,
3130+
) -> Result<EmptyResponse, sacp::Error> {
3131+
let thread_id = req.session_id.clone();
3132+
let project_id = req.project_id.clone();
3133+
self.update_thread_metadata(&thread_id, move |meta| {
3134+
meta.project_id = project_id;
3135+
})
3136+
.await?;
3137+
Ok(EmptyResponse {})
3138+
}
3139+
31183140
#[custom_method(CreateSourceRequest)]
31193141
async fn on_create_source(
31203142
&self,
31213143
req: CreateSourceRequest,
31223144
) -> Result<CreateSourceResponse, sacp::Error> {
3145+
// Resolve project_id to project_dir when creating a project-scoped source.
3146+
let project_dir = match (&req.project_id, &req.project_dir) {
3147+
(Some(pid), _) if !req.global => {
3148+
let dirs = goose::sources::project_working_dirs(pid);
3149+
Some(dirs.into_iter().next().ok_or_else(|| {
3150+
sacp::Error::invalid_params().data(format!(
3151+
"Project \"{}\" has no working directories configured",
3152+
pid
3153+
))
3154+
})?)
3155+
}
3156+
(_, Some(pd)) => Some(pd.clone()),
3157+
_ => None,
3158+
};
31233159
let source = goose::sources::create_source(
31243160
req.source_type,
31253161
&req.name,
31263162
&req.description,
31273163
&req.content,
31283164
req.global,
3129-
req.project_dir.as_deref(),
3165+
project_dir.as_deref(),
3166+
req.properties,
31303167
)?;
31313168
Ok(CreateSourceResponse { source })
31323169
}
@@ -3136,7 +3173,11 @@ impl GooseAcpAgent {
31363173
&self,
31373174
req: ListSourcesRequest,
31383175
) -> Result<ListSourcesResponse, sacp::Error> {
3139-
let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?;
3176+
let sources = goose::sources::list_sources(
3177+
req.source_type,
3178+
req.project_dir.as_deref(),
3179+
req.include_project_sources,
3180+
)?;
31403181
Ok(ListSourcesResponse { sources })
31413182
}
31423183

@@ -3152,6 +3193,7 @@ impl GooseAcpAgent {
31523193
&req.content,
31533194
req.global,
31543195
req.project_dir.as_deref(),
3196+
req.properties,
31553197
)?;
31563198
Ok(UpdateSourceResponse { source })
31573199
}

crates/goose-sdk/src/custom_requests.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,16 @@ pub struct UnarchiveSessionRequest {
215215
pub session_id: String,
216216
}
217217

218+
/// Set or clear the project associated with a session.
219+
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
220+
#[request(method = "_goose/session/set_project", response = EmptyResponse)]
221+
#[serde(rename_all = "camelCase")]
222+
pub struct SetSessionProjectRequest {
223+
pub session_id: String,
224+
/// The source name (kebab-case ID) of the project, or null to clear.
225+
pub project_id: Option<String>,
226+
}
227+
218228
/// Export a session as a JSON string.
219229
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
220230
#[request(method = "_goose/session/export", response = ExportSessionResponse)]
@@ -302,6 +312,7 @@ pub struct ProviderConfigKey {
302312
pub enum SourceType {
303313
#[default]
304314
Skill,
315+
Project,
305316
}
306317

307318
/// A source — a user-editable entity backed by an on-disk directory. Sources
@@ -319,6 +330,10 @@ pub struct SourceEntry {
319330
/// True when the source lives in the user's global sources directory; false
320331
/// when it lives inside a specific project.
321332
pub global: bool,
333+
/// Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,
334+
/// preferredProvider for projects). Stored in the frontmatter.
335+
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
336+
pub properties: std::collections::HashMap<String, serde_json::Value>,
322337
}
323338

324339
/// Create a new source (global or project-scoped).
@@ -335,6 +350,14 @@ pub struct CreateSourceRequest {
335350
/// Absolute path to the project root. Required when `global` is false.
336351
#[serde(default, skip_serializing_if = "Option::is_none")]
337352
pub project_dir: Option<String>,
353+
/// Project source ID. When set with `global: false`, the backend resolves
354+
/// the project's first working directory automatically. Takes precedence
355+
/// over `project_dir`.
356+
#[serde(default, skip_serializing_if = "Option::is_none")]
357+
pub project_id: Option<String>,
358+
/// Arbitrary key/value metadata.
359+
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
360+
pub properties: std::collections::HashMap<String, serde_json::Value>,
338361
}
339362

340363
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
@@ -353,6 +376,10 @@ pub struct ListSourcesRequest {
353376
pub source_type: Option<SourceType>,
354377
#[serde(default, skip_serializing_if = "Option::is_none")]
355378
pub project_dir: Option<String>,
379+
/// When true, also scan the working directories of all known projects for
380+
/// project-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`).
381+
#[serde(default)]
382+
pub include_project_sources: bool,
356383
}
357384

358385
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
@@ -374,6 +401,9 @@ pub struct UpdateSourceRequest {
374401
pub global: bool,
375402
#[serde(default, skip_serializing_if = "Option::is_none")]
376403
pub project_dir: Option<String>,
404+
/// Arbitrary key/value metadata. Replaces all existing properties.
405+
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
406+
pub properties: std::collections::HashMap<String, serde_json::Value>,
377407
}
378408

379409
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]

crates/goose/src/agents/agent.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,24 @@ impl Agent {
334334
messages
335335
}
336336

337+
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
338+
let thread_id = session.thread_id.as_deref()?;
339+
let thread_mgr =
340+
crate::session::ThreadManager::new(self.config.session_manager.storage().clone());
341+
let thread = thread_mgr.get_thread(thread_id).await.ok()?;
342+
let project_id = thread.metadata.project_id.as_deref()?;
343+
let entry = crate::sources::read_project(project_id).ok()?;
344+
let mut parts = Vec::new();
345+
parts.push(format!("# Project: {}", entry.name));
346+
if !entry.description.is_empty() {
347+
parts.push(entry.description.clone());
348+
}
349+
if !entry.content.is_empty() {
350+
parts.push(entry.content.clone());
351+
}
352+
Some(parts.join("\n\n"))
353+
}
354+
337355
async fn prepare_reply_context(
338356
&self,
339357
session_id: &str,
@@ -1194,6 +1212,14 @@ impl Agent {
11941212
goose_mode,
11951213
initial_messages,
11961214
} = context;
1215+
1216+
// Inject project instructions into the system prompt when the thread
1217+
// has a project_id set. This replaces the frontend-side prompt assembly
1218+
// that previously prepended project context to every user message.
1219+
if let Some(project_addendum) = self.load_project_instructions(&session).await {
1220+
system_prompt = format!("{system_prompt}\n\n{project_addendum}");
1221+
}
1222+
11971223
self.reset_retry_attempts().await;
11981224

11991225
let provider = self.provider().await?;

0 commit comments

Comments
 (0)