feat: add workspace memory backend for embedded workers

This commit is contained in:
2026-07-21 09:27:28 +09:00
parent 2e0cd3d161
commit 2572dde691
15 changed files with 947 additions and 235 deletions
+36 -24
View File
@@ -726,34 +726,46 @@ where
}
{
let workspace_client = worker.workspace_client().clone();
let worker = worker.engine_mut();
// Memory tools require both explicit feature exposure and memory storage
// configuration. This keeps resident-memory config separate from the
// model-visible Memory* tool surface.
// Memory tools require explicit feature exposure. Storage access may be
// provided by local filesystem authority or the path-free Workspace HTTP API.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
if let Some(workspace_root) = local_workspace_root.as_ref() {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
} else if let WorkspaceClient::Http {
workspace_id,
base_url,
} = workspace_client
{
for definition in crate::feature::builtin::memory::workspace_http_memory_tools(
workspace_id,
base_url,
) {
worker.register_tool(definition);
}
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let workspace_root = local_workspace_root.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"memory tools require local Worker filesystem authority",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
"memory tools require Workspace HTTP API or local Worker filesystem authority",
));
}
}
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
+14
View File
@@ -273,6 +273,20 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
Ok((manifest, PromptLoader::builtins_only()))
}
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
mut manifest: WorkerManifest,
workspace_root: &Path,
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
if manifest.worker.name.is_empty() {
manifest.worker.name = worker_name.to_string();
}
manifest.scope = ScopeConfig::default();
manifest.delegation_scope = ScopeConfig::default();
apply_plugin_resolution_plan(&mut manifest, workspace_root);
Ok((manifest, PromptLoader::builtins_only()))
}
fn load_single_manifest(
path: &Path,
explicit_worker_name: Option<&str>,
+1
View File
@@ -4,6 +4,7 @@
//! same descriptor-approved registry path used by feature modules. They are not
//! an external plugin-loading surface.
pub mod memory;
pub mod session_explore;
pub mod task;
pub mod ticket;
+261
View File
@@ -0,0 +1,261 @@
//! Workspace-HTTP backed Memory tools.
//!
//! Runtime workers may have Workspace authority without direct local filesystem
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult,
MemoryDeleteOperation, MemoryEditOperation, MemoryQueryOperation, MemoryReadOperation,
MemoryToolOutput, MemoryWriteOperation,
};
use serde::de::DeserializeOwned;
use serde_json::json;
#[derive(Clone, Debug)]
pub struct WorkspaceHttpMemoryBackend {
workspace_id: String,
base_url: String,
}
impl WorkspaceHttpMemoryBackend {
pub fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into(),
}
}
fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
let url = format!(
"{}/api/w/{}/memory/backend",
self.base_url.trim_end_matches('/'),
self.workspace_id
);
let response = reqwest::blocking::Client::new()
.post(url)
.json(&operation)
.send()
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
let status = response.status();
let body = response
.text()
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !status.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"workspace memory backend returned HTTP {status}: {body}"
)));
}
let response: MemoryBackendHttpResponse = serde_json::from_str(&body).map_err(|error| {
ToolError::ExecutionFailed(format!("decode memory backend response: {error}"))
})?;
match response {
MemoryBackendHttpResponse::Ok {
result: MemoryBackendOperationResult::ToolOutput(output),
} => Ok(tool_output(output)),
MemoryBackendHttpResponse::Ok { result } => Err(ToolError::ExecutionFailed(format!(
"unexpected memory backend result for model-visible tool: {result:?}"
))),
MemoryBackendHttpResponse::Error { message } => {
Err(ToolError::ExecutionFailed(message))
}
}
}
}
pub fn workspace_http_memory_tools(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
) -> Vec<ToolDefinition> {
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
vec![
memory_tool(
"MemoryRead",
READ_DESCRIPTION,
read_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Read(parse_input::<
MemoryReadOperation,
>(input)?))
},
),
memory_tool(
"MemoryWrite",
WRITE_DESCRIPTION,
write_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Write(parse_input::<
MemoryWriteOperation,
>(input)?))
},
),
memory_tool(
"MemoryEdit",
EDIT_DESCRIPTION,
edit_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Edit(parse_input::<
MemoryEditOperation,
>(input)?))
},
),
memory_tool(
"MemoryDelete",
DELETE_DESCRIPTION,
delete_schema(),
backend.clone(),
|input| {
Ok(MemoryBackendOperation::Delete(parse_input::<
MemoryDeleteOperation,
>(input)?))
},
),
memory_tool(
"MemoryQuery",
QUERY_DESCRIPTION,
query_schema(),
backend,
|input| {
Ok(MemoryBackendOperation::Query(parse_input::<
MemoryQueryOperation,
>(input)?))
},
),
]
}
type OperationBuilder = fn(&str) -> Result<MemoryBackendOperation, ToolError>;
fn memory_tool(
name: &'static str,
description: &'static str,
schema: serde_json::Value,
backend: WorkspaceHttpMemoryBackend,
build: OperationBuilder,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(schema.clone()),
Arc::new(WorkspaceHttpMemoryTool {
backend: backend.clone(),
build,
}) as Arc<dyn Tool>,
)
})
}
#[derive(Clone)]
struct WorkspaceHttpMemoryTool {
backend: WorkspaceHttpMemoryBackend,
build: OperationBuilder,
}
#[async_trait]
impl Tool for WorkspaceHttpMemoryTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let operation = (self.build)(input_json)?;
self.backend.execute(operation)
}
}
fn parse_input<T: DeserializeOwned>(input: &str) -> Result<T, ToolError> {
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
fn tool_output(output: MemoryToolOutput) -> ToolOutput {
ToolOutput {
summary: output.summary,
content: output.content,
}
}
const READ_DESCRIPTION: &str = "Read a durable memory record through Workspace authority.";
const WRITE_DESCRIPTION: &str =
"Create or overwrite a durable memory record through Workspace authority.";
const EDIT_DESCRIPTION: &str =
"Replace text in a durable memory record through Workspace authority.";
const DELETE_DESCRIPTION: &str = "Delete a durable memory record through Workspace authority.";
const QUERY_DESCRIPTION: &str = "Query durable memory records through Workspace authority.";
fn kind_schema() -> serde_json::Value {
json!({"type":"string","enum":["summary","decision","request"]})
}
fn read_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]},
"offset":{"type":["integer","null"],"minimum":0},
"limit":{"type":["integer","null"],"minimum":0}
}
})
}
fn write_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind","content"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]},
"content":{"type":"string"}
}
})
}
fn edit_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind","old_string","new_string"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]},
"old_string":{"type":"string"},
"new_string":{"type":"string"},
"replace_all":{"type":"boolean","default":false}
}
})
}
fn delete_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["kind"],
"properties":{
"kind": kind_schema(),
"slug":{"type":["string","null"]}
}
})
}
fn query_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"properties":{
"query":{"type":["string","null"]}
}
})
}
+5 -1
View File
@@ -4971,7 +4971,11 @@ fn prepare_worker_common_with_context(
let layout = memory::WorkspaceLayout::resolve(mem, &local.root);
scope_config.deny.extend(memory::deny_write_rules(&layout));
}
let scope = Scope::from_config(&scope_config).map_err(WorkerError::Scope)?;
let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() {
Scope::empty()
} else {
Scope::from_config(&scope_config).map_err(WorkerError::Scope)?
};
prepare_worker_common_from_scope(
manifest,
loader,