Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/goose-sdk/src/custom_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@ pub struct UnarchiveSessionRequest {
pub session_id: String,
}

/// Set or clear the project associated with a session.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/session/set_project", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct SetSessionProjectRequest {
pub session_id: String,
/// The source name (kebab-case ID) of the project, or null to clear.
pub project_id: Option<String>,
}

/// Export a session as a JSON string.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/session/export", response = ExportSessionResponse)]
Expand Down Expand Up @@ -259,6 +269,7 @@ pub struct ProviderConfigKey {
pub enum SourceType {
#[default]
Skill,
Project,
}

/// A source — a user-editable entity backed by an on-disk directory. Sources
Expand All @@ -276,6 +287,10 @@ pub struct SourceEntry {
/// True when the source lives in the user's global sources directory; false
/// when it lives inside a specific project.
pub global: bool,
/// 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>,
}

/// Create a new source (global or project-scoped).
Expand All @@ -292,6 +307,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)]
Expand All @@ -310,6 +333,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,
Comment on lines +699 to +702
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate ACP schema for includeProjectSources

ListSourcesRequest now defines include_project_sources in Rust, but the ACP schema/generated SDK contract still omits this field, so typed clients cannot request project-scoped source discovery without unsafe casts. This creates a contract drift between server behavior and published client types; update/regenerate schema artifacts to include includeProjectSources.

Useful? React with 👍 / 👎.

}

#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
Expand All @@ -331,6 +358,9 @@ pub struct UpdateSourceRequest {
pub global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
/// Arbitrary key/value metadata. Replaces all existing properties.
#[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)]
Expand Down
5 changes: 5 additions & 0 deletions crates/goose/acp-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@
"requestType": "UnarchiveSessionRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/session/set_project",
"requestType": "SetSessionProjectRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/sources/create",
"requestType": "CreateSourceRequest",
Expand Down
60 changes: 59 additions & 1 deletion crates/goose/acp-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,27 @@
"x-side": "agent",
"x-method": "_goose/session/unarchive"
},
"SetSessionProjectRequest": {
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"projectId": {
"type": [
"string",
"null"
],
"description": "The source name (kebab-case ID) of the project, or null to clear."
}
},
"required": [
"sessionId"
],
"description": "Set or clear the project associated with a session.",
"x-side": "agent",
"x-method": "_goose/session/set_project"
},
"CreateSourceRequest": {
"type": "object",
"properties": {
Expand All @@ -741,6 +762,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": {
Comment on lines 1480 to +1491
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add projectId to CreateSourceRequest schema

The ACP schema entry for CreateSourceRequest was updated for properties but still omits the new projectId request field introduced in Rust. That leaves generated SDK clients/types unable to send projectId, so typed consumers cannot use project-scoped source creation through the new convenience path.

Useful? React with 👍 / 👎.

"type": "object",
"additionalProperties": {},
"description": "Arbitrary key/value metadata."
}
},
"required": [
Expand All @@ -757,7 +790,8 @@
"SourceType": {
"type": "string",
"enum": [
"skill"
"skill",
"project"
],
"description": "The type of source entity."
},
Expand Down Expand Up @@ -796,6 +830,11 @@
"global": {
"type": "boolean",
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
},
"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": [
Expand Down Expand Up @@ -826,6 +865,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 sources. If `type` is omitted, sources of all known types are returned.\nBoth global and project-scoped sources are included when `project_dir` is set.",
Expand Down Expand Up @@ -871,6 +915,11 @@
"string",
"null"
]
},
"properties": {
"type": "object",
"additionalProperties": {},
"description": "Arbitrary key/value metadata. Replaces all existing properties."
}
},
"required": [
Expand Down Expand Up @@ -1534,6 +1583,15 @@
"description": "Params for _goose/session/unarchive",
"title": "UnarchiveSessionRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/SetSessionProjectRequest"
}
],
"description": "Params for _goose/session/set_project",
"title": "SetSessionProjectRequest"
},
{
"allOf": [
{
Expand Down
36 changes: 34 additions & 2 deletions crates/goose/src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3185,18 +3185,45 @@ impl GooseAcpAgent {
Ok(EmptyResponse {})
}

#[custom_method(SetSessionProjectRequest)]
async fn on_set_session_project(
&self,
req: SetSessionProjectRequest,
) -> Result<EmptyResponse, sacp::Error> {
let thread_id = req.session_id.clone();
let project_id = req.project_id.clone();
self.update_thread_metadata(&thread_id, move |meta| {
meta.project_id = project_id;
})
.await?;
Ok(EmptyResponse {})
}

#[custom_method(CreateSourceRequest)]
async fn on_create_source(
&self,
req: CreateSourceRequest,
) -> Result<CreateSourceResponse, sacp::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(|| {
sacp::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 })
}
Expand All @@ -3206,7 +3233,11 @@ impl GooseAcpAgent {
&self,
req: ListSourcesRequest,
) -> Result<ListSourcesResponse, sacp::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 })
}

Expand All @@ -3222,6 +3253,7 @@ impl GooseAcpAgent {
&req.content,
req.global,
req.project_dir.as_deref(),
req.properties,
)?;
Ok(UpdateSourceResponse { source })
}
Expand Down
23 changes: 23 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,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,
Expand Down Expand Up @@ -1194,6 +1212,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?;
Expand Down
Loading
Loading