diff --git a/crates/ov_cli/src/client.rs b/crates/ov_cli/src/client.rs index 143585acb8..a90802f987 100644 --- a/crates/ov_cli/src/client.rs +++ b/crates/ov_cli/src/client.rs @@ -1628,6 +1628,22 @@ impl HttpClient { self.get("/api/v1/snapshot/log", ¶ms).await } + pub async fn snapshot_diff( + &self, + path: &str, + from_ref: Option<&str>, + to_ref: &str, + ) -> Result { + let mut params = vec![ + ("path".to_string(), path.to_string()), + ("to".to_string(), to_ref.to_string()), + ]; + if let Some(from_ref) = from_ref { + params.push(("from".to_string(), from_ref.to_string())); + } + self.get("/api/v1/snapshot/diff", ¶ms).await + } + pub async fn snapshot_ignore_get(&self) -> Result { self.get("/api/v1/snapshot/ignore", &[]).await } @@ -1965,6 +1981,23 @@ mod tests { assert_gateway_token_retry(&requests); } + #[tokio::test] + async fn snapshot_diff_sends_path_and_refs() { + let (base_url, request_rx) = spawn_request_capture_server().await; + let client = HttpClient::new(base_url, None, None, None, None, 5.0, false, None); + + client + .snapshot_diff("viking://resources/a.md", Some("old"), "new") + .await + .expect("snapshot diff should succeed"); + + let request = request_rx.await.expect("request should be captured"); + assert!(request.starts_with("GET /api/v1/snapshot/diff?")); + assert!(request.contains("path=viking%3A%2F%2Fresources%2Fa.md")); + assert!(request.contains("from=old")); + assert!(request.contains("to=new")); + } + fn assert_gateway_token_retry(requests: &[String]) { assert_eq!(requests.len(), 2); assert!(!requests[0].to_ascii_lowercase().contains("x-gateway-token")); diff --git a/crates/ov_cli/src/commands/mod.rs b/crates/ov_cli/src/commands/mod.rs index 5fb3e488c3..f48f1ab76e 100644 --- a/crates/ov_cli/src/commands/mod.rs +++ b/crates/ov_cli/src/commands/mod.rs @@ -15,4 +15,5 @@ pub mod skills; pub mod snapshot; pub mod system; pub mod task; +pub mod user_settings; pub mod watch; diff --git a/crates/ov_cli/src/commands/snapshot.rs b/crates/ov_cli/src/commands/snapshot.rs index 3d119f2819..4f8f32d167 100644 --- a/crates/ov_cli/src/commands/snapshot.rs +++ b/crates/ov_cli/src/commands/snapshot.rs @@ -68,10 +68,23 @@ pub async fn dispatch( limit, paths, } => { - let value = client.snapshot_log(&branch, limit, paths.as_deref()).await?; + let value = client + .snapshot_log(&branch, limit, paths.as_deref()) + .await?; print_log(&value, output_format, compact); Ok(()) } + SnapshotCmd::Diff { + path, + from_ref, + to_ref, + } => { + let value = client + .snapshot_diff(&path, from_ref.as_deref(), &to_ref) + .await?; + print_diff(&value, output_format, compact); + Ok(()) + } SnapshotCmd::IgnoreGet => { let value = client.snapshot_ignore_get().await?; print_ignore_get(&value, output_format, compact); @@ -91,6 +104,17 @@ pub async fn dispatch( } } +fn print_diff(value: &Value, output_format: OutputFormat, compact: bool) { + if matches!(output_format, OutputFormat::Json) { + output_success(value, output_format, compact); + return; + } + let diff = value.get("diff_text").and_then(Value::as_str).unwrap_or(""); + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(diff.as_bytes()); + let _ = stdout.flush(); +} + /// Resolve `.ovgitignore` content: `--file` takes precedence over `--content`; /// if neither is given, the content is left empty (clears the rules). fn resolve_ignore_content(content: Option<&str>, file: Option<&std::path::Path>) -> Result { diff --git a/crates/ov_cli/src/commands/user_settings.rs b/crates/ov_cli/src/commands/user_settings.rs new file mode 100644 index 0000000000..1df2b114be --- /dev/null +++ b/crates/ov_cli/src/commands/user_settings.rs @@ -0,0 +1,40 @@ +use crate::client::HttpClient; +use crate::error::{Error, Result}; +use crate::output::{OutputFormat, output_success}; +use serde_json::{Map, Value}; + +pub async fn get_memory( + client: &HttpClient, + output_format: OutputFormat, + compact: bool, +) -> Result<()> { + let response: Value = client.get("/api/v1/user-settings/memory", &[]).await?; + output_success(&response, output_format, compact); + Ok(()) +} + +pub async fn patch_memory( + client: &HttpClient, + agent_evolution_enabled: Option, + clear_agent_evolution_enabled: bool, + output_format: OutputFormat, + compact: bool, +) -> Result<()> { + let mut body = Map::new(); + if clear_agent_evolution_enabled { + body.insert("agent_evolution_enabled".into(), Value::Null); + } else if let Some(enabled) = agent_evolution_enabled { + body.insert("agent_evolution_enabled".into(), Value::Bool(enabled)); + } + if body.is_empty() { + return Err(Error::Client( + "set-memory requires at least one setting or clear flag".to_string(), + )); + } + + let response: Value = client + .patch("/api/v1/user-settings/memory", &Value::Object(body), &[]) + .await?; + output_success(&response, output_format, compact); + Ok(()) +} diff --git a/crates/ov_cli/src/help_ui.rs b/crates/ov_cli/src/help_ui.rs index e075af8ebd..0c3d7fb2cb 100644 --- a/crates/ov_cli/src/help_ui.rs +++ b/crates/ov_cli/src/help_ui.rs @@ -83,7 +83,14 @@ const CONFIG_STATUS: &[HelpCommand] = help_commands![ ]; const IMPORT_EXPORT_SESSIONS: &[HelpCommand] = help_commands![ - "import", "export", "backup", "restore", "snapshot", "session", "privacy" + "import", + "export", + "backup", + "restore", + "snapshot", + "session", + "user-settings", + "privacy" ]; const INTERACTIVE_ADMIN: &[HelpCommand] = help_commands![ @@ -525,9 +532,27 @@ const COMMAND_HELP_SPECS: &[CommandHelpSpec] = &[ description: "Show exact arguments for a session operation.", }], }, + CommandHelpSpec { + path: &["user-settings"], + purpose: "Manage current-user memory extraction and Agent Evolution settings.", + examples: &[ + HelpItem { + label: "ov user-settings memory", + description: "Show user overrides and effective memory settings.", + }, + HelpItem { + label: "ov user-settings set-memory --agent-evolution-enabled true", + description: "Enable future trajectory and experience production.", + }, + ], + next_steps: &[HelpItem { + label: "ov user-settings set-memory --help", + description: "Show Agent Evolution override options.", + }], + }, CommandHelpSpec { path: &["snapshot"], - purpose: "Manage workspace snapshots: commit, restore, show, and walk history.", + purpose: "Manage workspace snapshots: commit, restore, show, diff, and walk history.", examples: &[ HelpItem { label: "ov snapshot commit -m \"checkpoint before refactor\"", @@ -537,6 +562,10 @@ const COMMAND_HELP_SPECS: &[CommandHelpSpec] = &[ label: "ov snapshot log --branch main", description: "Walk commit history, newest first.", }, + HelpItem { + label: "ov snapshot diff viking://docs/spec.md --from --to ", + description: "Compare one file between two snapshots.", + }, HelpItem { label: "ov snapshot restore viking://projects/acme --dry-run", description: "Preview restoring a directory to a past snapshot.", @@ -625,6 +654,24 @@ const COMMAND_HELP_SPECS: &[CommandHelpSpec] = &[ description: "Inspect a commit from the log.", }], }, + CommandHelpSpec { + path: &["snapshot", "diff"], + purpose: "Compare one file between two snapshots as a unified diff.", + examples: &[ + HelpItem { + label: "ov snapshot diff viking://docs/spec.md --from --to ", + description: "Compare the file contents at two commits.", + }, + HelpItem { + label: "ov snapshot diff viking://docs/spec.md --to ", + description: "Compare an empty file with the file at a commit.", + }, + ], + next_steps: &[HelpItem { + label: "ov snapshot log --paths viking://docs/spec.md", + description: "Find commits that changed this file.", + }], + }, CommandHelpSpec { path: &["snapshot", "ignore-get"], purpose: "Show the account-level .ovgitignore content.", @@ -1865,11 +1912,12 @@ fn localized_command_purpose(spec: &CommandHelpSpec, language: Language) -> &str ["health"] => "快速检查服务器是否可连接。", ["status"] => "查看 OpenViking 服务器诊断状态。", ["language"] => "选择 OpenViking CLI 显示语言。", - ["snapshot"] => "管理工作区快照:提交、恢复、查看,以及遍历历史。", + ["snapshot"] => "管理工作区快照:提交、恢复、查看、对比,以及遍历历史。", ["snapshot", "commit"] => "将当前工作区状态提交为新的快照。", ["snapshot", "restore"] => "通过一次前向提交,将项目目录恢复到历史快照。", ["snapshot", "show"] => "查看某次提交的元数据,或指定路径下的单个文件内容。", ["snapshot", "log"] => "按分支遍历提交历史,最新的在前。", + ["snapshot", "diff"] => "对比指定文件在两个快照中的内容差异。", _ => spec.purpose, } } @@ -2328,6 +2376,7 @@ fn localized_command_description<'a>( "task" => "查看异步任务", "observer" => "观察服务器组件", "session" => "管理会话", + "user-settings" => "管理当前用户记忆配置", "import" => "导入 .ovpack", "export" => "导出为 .ovpack", "backup" => "创建仅恢复备份", @@ -2549,7 +2598,15 @@ fn version() -> String { fn is_bare_group_help_command(command: &str) -> bool { matches!( command, - "task" | "skills" | "session" | "snapshot" | "privacy" | "admin" | "system" | "observer" + "task" + | "skills" + | "session" + | "user-settings" + | "snapshot" + | "privacy" + | "admin" + | "system" + | "observer" ) } @@ -3089,6 +3146,13 @@ mod tests { assert!(session.contains("add-message ")); assert!(session.contains("add-messages ")); + let user_settings = strip_ansi( + &render_command_help_request(&os_args(&["ov", "user-settings", "--help"])) + .expect("user settings help should render"), + ); + assert!(user_settings.contains("memory")); + assert!(user_settings.contains("set-memory")); + let watch = strip_ansi( &render_command_help_request(&os_args(&["ov", "task", "watch", "--help"])) .expect("watch help should render"), diff --git a/crates/ov_cli/src/main.rs b/crates/ov_cli/src/main.rs index 634ea908b0..0f355aef86 100644 --- a/crates/ov_cli/src/main.rs +++ b/crates/ov_cli/src/main.rs @@ -790,6 +790,11 @@ enum Commands { #[command(subcommand)] action: SessionCommands, }, + /// [Data] Manage current-user memory and Agent Evolution settings + UserSettings { + #[command(subcommand)] + action: UserSettingsCommands, + }, /// [Experimental][Data] Add memory in one shot (creates session, adds messages, commits) AddMemory { /// Content to memorize. Plain string (treated as user message), @@ -954,7 +959,7 @@ enum Commands { #[command(subcommand)] action: TaskCommands, }, - /// [Version] Manage workspace snapshots (commit, restore, show, log) + /// [Version] Manage workspace snapshots (commit, restore, show, diff, log) Snapshot { #[command(subcommand)] cmd: SnapshotCmd, @@ -1135,6 +1140,17 @@ pub(crate) enum SnapshotCmd { #[arg(long, value_delimiter = ',')] paths: Option>, }, + /// Compare one file between two snapshots + Diff { + /// viking:// URI of the file to compare + path: String, + /// Older commit oid, branch, or tag; omit to compare an empty file + #[arg(long = "from")] + from_ref: Option, + /// Newer commit oid, branch, or tag + #[arg(long = "to")] + to_ref: String, + }, /// Get the account .ovgitignore content IgnoreGet, /// Set the account .ovgitignore content @@ -1562,6 +1578,21 @@ enum PrivacyCommands { }, } +#[derive(Subcommand)] +enum UserSettingsCommands { + /// Show current-user memory setting overrides and effective values + Memory, + /// Partially update current-user memory settings + SetMemory { + /// Enable or disable Agent Evolution memory production + #[arg(long, conflicts_with = "clear_agent_evolution_enabled")] + agent_evolution_enabled: Option, + /// Clear the user Agent Evolution override + #[arg(long, conflicts_with = "agent_evolution_enabled")] + clear_agent_evolution_enabled: bool, + }, +} + #[derive(Subcommand)] enum AdminCommands { /// Create a new account with its first admin user @@ -2031,7 +2062,15 @@ fn allows_plain_help_as_user_input(command: &str) -> bool { fn is_plain_help_group(command: &str) -> bool { matches!( command, - "config" | "task" | "admin" | "system" | "session" | "privacy" | "observer" | "skills" + "config" + | "task" + | "admin" + | "system" + | "session" + | "user-settings" + | "privacy" + | "observer" + | "skills" ) } @@ -2208,6 +2247,7 @@ fn is_top_level_server_command(command: &str) -> bool { | "grep" | "glob" | "add-memory" + | "user-settings" | "relations" | "link" | "unlink" @@ -2993,6 +3033,28 @@ async fn main() { Commands::System { action } => handlers::handle_system(action, ctx).await, Commands::Observer { action } => handlers::handle_observer(action, ctx).await, Commands::Session { action } => handlers::handle_session(action, ctx).await, + Commands::UserSettings { action } => { + let client = ctx.get_client(); + match action { + UserSettingsCommands::Memory => { + commands::user_settings::get_memory(&client, ctx.output_format, ctx.compact) + .await + } + UserSettingsCommands::SetMemory { + agent_evolution_enabled, + clear_agent_evolution_enabled, + } => { + commands::user_settings::patch_memory( + &client, + agent_evolution_enabled, + clear_agent_evolution_enabled, + ctx.output_format, + ctx.compact, + ) + .await + } + } + } Commands::Admin { action } => handlers::handle_admin(action, ctx).await, Commands::Privacy { action } => handlers::handle_privacy(action, ctx).await, Commands::Ls { @@ -3220,10 +3282,11 @@ async fn main() { mod tests { use super::{ Cli, CliContext, Commands, ConfigAddTarget, ConfigCommands, LanguageGateAction, - PrivacyCommands, SkillCommands, UploadCliOptions, find_command_index, first_command_token, - is_language_command_request, language_command_can_run_picker, language_gate_action, - language_required_message, legacy_upload_option_error, plain_help_misuse, - pre_parse_requires_cli_config_file, preprocess_cli_args, preprocess_privacy_args, + PrivacyCommands, SkillCommands, SnapshotCmd, UploadCliOptions, UserSettingsCommands, + find_command_index, first_command_token, is_language_command_request, + language_command_can_run_picker, language_gate_action, language_required_message, + legacy_upload_option_error, plain_help_misuse, pre_parse_requires_cli_config_file, + preprocess_cli_args, preprocess_privacy_args, }; use crate::config::{Config, DEFAULT_CUSTOM_URL}; use crate::output::OutputFormat; @@ -3254,6 +3317,37 @@ mod tests { assert_eq!(cli.actor_peer_id.as_deref(), Some("peer-a")); } + #[test] + fn cli_parses_snapshot_diff_refs() { + let cli = Cli::try_parse_from([ + "ov", + "snapshot", + "diff", + "viking://resources/a.md", + "--from", + "old", + "--to", + "new", + ]) + .expect("snapshot diff should parse"); + + match cli.command { + Commands::Snapshot { + cmd: + SnapshotCmd::Diff { + path, + from_ref, + to_ref, + }, + } => { + assert_eq!(path, "viking://resources/a.md"); + assert_eq!(from_ref.as_deref(), Some("old")); + assert_eq!(to_ref, "new"); + } + _ => panic!("expected snapshot diff"), + } + } + #[test] fn cli_parses_find_context_type() { let cli = @@ -3310,6 +3404,55 @@ mod tests { } } + #[test] + fn cli_parses_user_memory_settings_commands() { + assert!(pre_parse_requires_cli_config_file(&os_args(&[ + "ov", + "user-settings", + "memory", + ]))); + let get = Cli::try_parse_from(["ov", "user-settings", "memory"]) + .expect("user memory settings get should parse"); + assert!(matches!( + get.command, + Commands::UserSettings { + action: UserSettingsCommands::Memory + } + )); + + let set = Cli::try_parse_from([ + "ov", + "user-settings", + "set-memory", + "--agent-evolution-enabled", + "true", + ]) + .expect("user memory settings patch should parse"); + match set.command { + Commands::UserSettings { + action: + UserSettingsCommands::SetMemory { + agent_evolution_enabled, + .. + }, + } => { + assert_eq!(agent_evolution_enabled, Some(true)); + } + _ => panic!("expected user-settings set-memory command"), + } + + assert!( + Cli::try_parse_from([ + "ov", + "user-settings", + "set-memory", + "--memory-types", + "profile,events", + ]) + .is_err() + ); + } + #[test] fn cli_parses_search_context_type() { let cli = Cli::try_parse_from(["ov", "search", "invoice", "--context-type", "skill"]) diff --git a/docs/design/agent-evolution-user-configuration.md b/docs/design/agent-evolution-user-configuration.md new file mode 100644 index 0000000000..1c65b6b664 --- /dev/null +++ b/docs/design/agent-evolution-user-configuration.md @@ -0,0 +1,157 @@ +# OpenViking Agent 进化用户配置设计 + +## 1. 目标 + +本期提供用户级 `agent_evolution_enabled` 开关,控制后续 session commit 是否生成或更新 Agent 记忆: + +- `trajectories` +- `experiences` + +配置不存在时默认关闭。关闭不删除已有文件,也不影响已有 experience 的检索和读取。 + +本期不提供用户级持久 `memory_types`。现有 session 级 `memory_policy.memory_types` 保留,继续作为单次 commit 的记忆类型 allow-list。 + +## 2. 配置模型 + +用户配置写入现有路径: + +```text +viking://user//settings/user_config.json +``` + +配置结构: + +```json +{ + "add_targets": { + "resource_uri": null, + "skill_uri": null + }, + "agent_evolution": { + "enabled": false + } +} +``` + +`agent_evolution.enabled` 未设置时,按以下优先级解析: + +```text +用户显式配置 > server.user_config_defaults > false +``` + +修改 Agent 进化配置不得覆盖同一文件中的 `add_targets`。 + +## 3. 生效规则 + +每次 commit 重新读取当前用户配置,已创建的 session 不缓存开关状态。 + +- `agent_evolution_enabled=false`:从本次 commit 的有效类型中移除 `trajectories` 和 `experiences`。 +- `agent_evolution_enabled=true`:不改写 session 的 `memory_policy.memory_types`。 +- session 未设置 `memory_policy.memory_types`:使用当前已启用的默认记忆类型。 +- session 显式设置 `memory_policy.memory_types`:仅本次 commit 按该 allow-list 提取。 + +因此用户级开关是 Agent 记忆生产的总开关,session policy 是单次请求的进一步限制。session policy 不能绕过已关闭的用户级开关。 + +Agent 进化关闭时,以下行为保持不变: + +- session 原始 messages 归档。 +- Working Memory 摘要。 +- profile、preferences、events、cases 等非 Agent 记忆提取。 +- 已有 trajectory 和 experience 文件的 list、find、search、read。 + +## 4. Commit 流程 + +1. 读取并校验 session `memory_policy`。 +2. 读取当前用户的 `user_config.json`。 +3. 解析 `agent_evolution_enabled`。 +4. 开关关闭时,从 session policy 中移除 `trajectories` 和 `experiences`。 +5. 归档原始 session messages。 +6. 按最终 session policy 运行记忆提取。 +7. 在 task result 中返回 `effective_memory_types`、`agent_evolution_enabled` 和 `agent_memory_skip_reason`。 + +`agent_memory_skip_reason` 可能为: + +- `agent_evolution_disabled` +- `memory_types_filtered` +- `invalid_user_config` + +用户配置非法时,commit 仍完成归档并保留非 Agent 记忆处理能力,但本次不生成或更新 trajectory/experience。 + +队列消息显式保存 commit 时解析出的开关和最终 session policy,确保异步 Phase 2 不受后续用户配置变化影响。旧队列消息没有开关字段时按历史行为处理,即默认开启 Agent 记忆生产。 + +## 5. API + +读取当前用户设置: + +```http +GET /api/v1/user-settings/memory +``` + +响应: + +```json +{ + "status": "ok", + "result": { + "override": { + "agent_evolution_enabled": null + }, + "effective": { + "agent_evolution_enabled": false + } + } +} +``` + +部分更新当前用户设置: + +```http +PATCH /api/v1/user-settings/memory +Content-Type: application/json + +{ + "agent_evolution_enabled": true +} +``` + +字段不传表示不修改。显式传 `null` 表示清除用户覆盖值,回退到部署默认值或内置默认值。请求中的未知字段会被拒绝。 + +## 6. SDK 与 CLI + +Python SDK: + +```python +settings = await client.get_memory_settings() +await client.patch_memory_settings(agent_evolution_enabled=True) +await client.patch_memory_settings(agent_evolution_enabled=None) +``` + +HTTP SDK 与 embedded 模式的 `AsyncOpenViking` / `SyncOpenViking` 均提供相同方法。 + +CLI: + +```bash +ov user-settings memory +ov user-settings set-memory --agent-evolution-enabled true +ov user-settings set-memory --agent-evolution-enabled false +ov user-settings set-memory --clear-agent-evolution-enabled +``` + +## 7. 存量兼容 + +- 没有显式配置的新增用户和存量用户,有效值均为 `false`。 +- 存量 trajectory/experience 文件保持原 URI 和检索可见性。 +- `user_config.json` 的更新使用文件锁执行 read-modify-write,避免并发覆盖 `add_targets` 或 Agent 进化配置。 + +## 8. Future TODO:企业版 + +本期不实现企业管理员代管、跨用户批量配置、用户列表和企业控制台交互。未来企业版复用同一 `UserConfig`、配置读写服务和 commit 生效逻辑,不在内核增加个人版/企业版分支。 + +## 9. 验收标准 + +- 配置不存在时,Agent 进化有效值为 `false`。 +- 关闭时不生成或更新 trajectory/experience,归档和其他记忆正常处理。 +- 开启后,同一 session 的下一次 commit 即可恢复 Agent 记忆生产。 +- session 级 `memory_policy.memory_types` 仍能限制单次 commit。 +- PATCH Agent 进化设置不覆盖 `add_targets`。 +- API、SDK 和 CLI 不再提供用户级 `memory_types`。 diff --git a/docs/en/api/01-overview.md b/docs/en/api/01-overview.md index a12d4c9b77..f49af09aaa 100644 --- a/docs/en/api/01-overview.md +++ b/docs/en/api/01-overview.md @@ -613,3 +613,4 @@ Subsequent API documentation is organized by functional module as follows: | [Privacy Configs](10-privacy.md) - Privacy config version management and activation | Privacy configuration | | [Metrics](09-metrics.md) - Prometheus metrics export and scraping guide | Metrics documentation | | [Admin](08-admin.md) - Multi-tenant management API | Multi-tenant account and user management | +| [User Memory Settings](12-user-settings.md) | Per-user memory types and Agent Evolution switch | diff --git a/docs/en/api/11-snapshot.md b/docs/en/api/11-snapshot.md index e7bf6d8744..875d331009 100644 --- a/docs/en/api/11-snapshot.md +++ b/docs/en/api/11-snapshot.md @@ -4,13 +4,14 @@ On top of VikingFS, OpenViking provides Git-based multi-version management, call Snapshots are powered by [gitoxide](https://github.com/Byron/gitoxide) embedded in the Rust RAGFS layer, maintaining one logical Git repository per `account_id`. This is fully transparent to callers — you never touch a `.ovgit` directory, the object store, or ref internals. -The four core commands: +The five core commands: | Command | Purpose | |---------|---------| | `commit` | Save the current workspace state as a new snapshot | | `log` | Walk commit history starting from the newest | | `show` | View a commit's metadata, or read a file's content from that commit | +| `diff` | Compare one file between two snapshots as a unified diff | | `restore` | Restore a directory (or the whole account tree) to a past snapshot | In addition, account-level `.ovgitignore` exclusion rules can be managed (`get`/`set`/`delete`) to exclude matching files from `commit`. See [Ignore management](#ignore-management). @@ -26,7 +27,7 @@ In addition, account-level `.ovgitignore` exclusion rules can be managed (`get`/ - HTTP routes: [snapshot.py](https://github.com/volcengine/OpenViking/blob/main/openviking/server/routers/snapshot.py), prefix `/api/v1/snapshot`. - SDK namespace: [snapshot_namespace.py](https://github.com/volcengine/OpenViking/blob/main/openviking/snapshot_namespace.py), exposed as `client.snapshot.*`. -- Underlying semantics: `commit` / `restore` / `show` / `log` in [viking_fs.py](https://github.com/volcengine/OpenViking/blob/main/openviking/storage/viking_fs.py). +- Underlying semantics: `commit` / `restore` / `show` / `log` / `diff` in [viking_fs.py](https://github.com/volcengine/OpenViking/blob/main/openviking/storage/viking_fs.py). - CLI: the `SnapshotCmd` in [main.rs](https://github.com/volcengine/OpenViking/blob/main/crates/ov_cli/src/main.rs), subcommands in [snapshot.rs](https://github.com/volcengine/OpenViking/blob/main/crates/ov_cli/src/commands/snapshot.rs). ## API Reference @@ -298,6 +299,58 @@ ov snapshot show 3f2a1b9c --path viking://resources/my_project/guide.md --out-fi --- +### diff() + +Compare one UTF-8 file between two snapshot refs and return a unified diff. `to_ref` is required. When `from_ref` is omitted, the older side is treated as an empty file, which is useful for displaying the initial version. + +**Python SDK (Embedded / HTTP)** + +```python +result = client.snapshot.diff( + "viking://resources/my_project/guide.md", + from_ref="3f2a1b9c", + to_ref="9a0b1c2d", +) +print(result["diff_text"]) +``` + +**TypeScript SDK** + +```typescript +const result = await client.gitDiff( + "viking://resources/my_project/guide.md", + "9a0b1c2d", + "3f2a1b9c", +); +console.log(result.diff_text); +``` + +**HTTP API** + +``` +GET /api/v1/snapshot/diff?path={uri}&from={old_ref}&to={new_ref} +``` + +```bash +curl --get "http://localhost:1933/api/v1/snapshot/diff" \ + --data-urlencode "path=viking://resources/my_project/guide.md" \ + --data-urlencode "from=3f2a1b9c" \ + --data-urlencode "to=9a0b1c2d" \ + -H "X-API-Key: your-key" +``` + +**CLI** + +```bash +ov snapshot diff viking://resources/my_project/guide.md \ + --from 3f2a1b9c \ + --to 9a0b1c2d +``` + +The response contains `path`, resolved `from_commit` and `to_commit`, `change_type` (`added`, `deleted`, `modified`, or `unchanged`), and `diff_text`. + +--- + ### restore() Restore a directory (or the whole account tree) to its state at `source_commit`. diff --git a/docs/en/api/12-user-settings.md b/docs/en/api/12-user-settings.md new file mode 100644 index 0000000000..30f785d070 --- /dev/null +++ b/docs/en/api/12-user-settings.md @@ -0,0 +1,89 @@ +# Agent Evolution User Settings + +User settings control whether future session commits generate or update `trajectories` and `experiences`. Settings are isolated per user and stored at `viking://user//settings/user_config.json`. + +## Effective Behavior + +- `agent_evolution_enabled` defaults to `false` when it is not explicitly configured. +- Disabling Agent Evolution does not stop session archiving, Working Memory, or other memory types. +- Disabling it does not delete existing trajectories or experiences and does not affect experience retrieval or reads. +- When enabled, session-level `memory_policy.memory_types` can still restrict memory types for one commit. + +This API does not provide a persistent user-level `memory_types` setting. Use session `memory_policy.memory_types` for per-commit filtering. + +## Get Settings + +```http +GET /api/v1/user-settings/memory +``` + +`override` contains the value explicitly stored for the current user. `effective` includes deployment and built-in defaults. GET does not create a configuration file. + +```bash +curl http://localhost:1933/api/v1/user-settings/memory \ + -H "X-API-Key: your-key" +``` + +Example response: + +```json +{ + "status": "ok", + "result": { + "override": { + "agent_evolution_enabled": null + }, + "effective": { + "agent_evolution_enabled": false + } + } +} +``` + +Python SDK: + +```python +settings = await client.get_memory_settings() +``` + +CLI: + +```bash +ov user-settings memory +``` + +## Update Settings + +```http +PATCH /api/v1/user-settings/memory +Content-Type: application/json +``` + +`agent_evolution_enabled` accepts a boolean, `null`, or omission. `null` clears the user override; omission leaves the setting unchanged. + +```bash +curl -X PATCH http://localhost:1933/api/v1/user-settings/memory \ + -H "X-API-Key: your-key" \ + -H "Content-Type: application/json" \ + -d '{"agent_evolution_enabled": true}' +``` + +Python SDK: + +```python +await client.patch_memory_settings(agent_evolution_enabled=True) +await client.patch_memory_settings(agent_evolution_enabled=None) +``` + +CLI: + +```bash +ov user-settings set-memory --agent-evolution-enabled true +ov user-settings set-memory --agent-evolution-enabled false +ov user-settings set-memory --clear-agent-evolution-enabled +``` + +## Related Documentation + +- [Sessions](05-sessions.md) - Create sessions, configure `memory_policy`, and commit +- [Retrieval](06-retrieval.md) - Retrieve existing experiences diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index 3b4906ca71..9582fe79f5 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -1445,6 +1445,9 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: "add_targets": { "resource_uri": "viking://user/resources", "skill_uri": "viking://user/skills" + }, + "agent_evolution": { + "enabled": false } } } @@ -1466,12 +1469,13 @@ When running OpenViking as an HTTP service, add a `server` section to `ov.conf`: | `temp_upload.shared_prefix` | str | URI prefix used when allocating shared `temp_file_id` objects. | `"viking://upload"` | | `user_config_defaults.add_targets.resource_uri` | str | Deployment default resource add directory used when `add_resource` omits both `to` and `parent`. `viking://user/...` resolves per request user. | `null` | | `user_config_defaults.add_targets.skill_uri` | str | Deployment default skill add root used when `add_skill` omits `target_uri`. Only `viking://user/skills` and `viking://agent/skills` are accepted. | `null` | +| `user_config_defaults.agent_evolution.enabled` | bool | Deployment default controlling whether session commits generate or update trajectories and experiences when the user has no explicit override. | `null` (effective value is `false`) | `api_key` mode uses API keys and is the default. `trusted` mode trusts `X-OpenViking-Account` / `X-OpenViking-User` headers from a trusted gateway or internal caller. When `root_api_key` is configured in `api_key` mode, the server enables multi-tenant authentication. Use the Admin API to create accounts and user keys. In `trusted` mode, ordinary requests do not require user registration first; each request is resolved as `USER` from the injected identity headers. However, skipping `root_api_key` in `trusted` mode is allowed only on localhost. Development mode only applies when `auth_mode = "api_key"` and `root_api_key` is not set. -`user_config_defaults` sets defaults for new and existing users when they have no per-user override. For add operations, explicit request targets still win: `add_resource.to` / `add_resource.parent` take precedence over user defaults, and `add_skill.target_uri` takes precedence over user defaults. Per-user overrides are stored in `viking://user/{user_id}/settings/user_config.json`. +`user_config_defaults` sets defaults for new and existing users when they have no per-user override. For add operations, explicit request targets still win: `add_resource.to` / `add_resource.parent` take precedence over user defaults, and `add_skill.target_uri` takes precedence over user defaults. When `agent_evolution.enabled` is unset at both levels, its effective value is `false`. Per-user overrides are stored in `viking://user/{user_id}/settings/user_config.json`. Supported add target URIs: diff --git a/docs/zh/api/01-overview.md b/docs/zh/api/01-overview.md index 8936e50813..a67d08e3fe 100644 --- a/docs/zh/api/01-overview.md +++ b/docs/zh/api/01-overview.md @@ -608,3 +608,4 @@ VikingBot API 需要服务器启动时指定 `--with-bot` 选项: | [隐私配置](10-privacy.md) | 隐私配置版本管理与切换 | | [指标与 Metrics](09-metrics.md) | Prometheus 指标导出与抓取说明 | | [管理员](08-admin.md) | 多租户账号和用户管理 | +| [用户记忆设置](12-user-settings.md) | 用户级记忆类型和 Agent 进化开关 | diff --git a/docs/zh/api/11-snapshot.md b/docs/zh/api/11-snapshot.md index ff8a63c2b5..018df088c6 100644 --- a/docs/zh/api/11-snapshot.md +++ b/docs/zh/api/11-snapshot.md @@ -4,13 +4,14 @@ OpenViking 在 VikingFS 之上提供了一套基于 Git 的多版本管理能力 快照能力底层由内嵌在 Rust RAGFS 层的 [gitoxide](https://github.com/Byron/gitoxide) 驱动,按 `account_id` 维护一个逻辑 Git 仓库(每个账号一个仓库),对调用方完全透明——你无需关心 `.ovgit` 目录、对象库或引用细节。 -四个核心命令: +五个核心命令: | 命令 | 作用 | |------|------| | `commit` | 把当前工作区状态保存成一个新快照 | | `log` | 从最新提交开始回溯历史 | | `show` | 查看某个提交的元数据,或读取该提交中某个文件的内容 | +| `diff` | 以 unified diff 格式对比某个文件在两个快照中的内容 | | `restore` | 把目录(或整棵账号树)恢复到某个历史快照的状态 | 此外还提供账号级 `.ovgitignore` 排除规则的管理命令(`get`/`set`/`delete`),用于在 `commit` 时按规则排除匹配的文件。详见 [ignore 管理](#ignore-管理)。 @@ -26,7 +27,7 @@ OpenViking 在 VikingFS 之上提供了一套基于 Git 的多版本管理能力 - HTTP 路由:[snapshot.py](https://github.com/volcengine/OpenViking/blob/main/openviking/server/routers/snapshot.py),前缀 `/api/v1/snapshot`。 - 命名空间(SDK):[snapshot_namespace.py](https://github.com/volcengine/OpenViking/blob/main/openviking/snapshot_namespace.py),暴露为 `client.snapshot.*`。 -- 底层语义实现:[viking_fs.py](https://github.com/volcengine/OpenViking/blob/main/openviking/storage/viking_fs.py) 的 `commit` / `restore` / `show` / `log`。 +- 底层语义实现:[viking_fs.py](https://github.com/volcengine/OpenViking/blob/main/openviking/storage/viking_fs.py) 的 `commit` / `restore` / `show` / `log` / `diff`。 - CLI 命令:[main.rs](https://github.com/volcengine/OpenViking/blob/main/crates/ov_cli/src/main.rs) 的 `SnapshotCmd`,子命令 [snapshot.rs](https://github.com/volcengine/OpenViking/blob/main/crates/ov_cli/src/commands/snapshot.rs)。 ## API 参考 @@ -298,6 +299,58 @@ ov snapshot show 3f2a1b9c --path viking://resources/my_project/guide.md --out-fi --- +### diff() + +对比一个 UTF-8 文件在两个快照引用中的内容,并返回 unified diff。`to_ref` 必填;省略 `from_ref` 时,旧版本按空文件处理,可用于展示文件的初始版本。 + +**Python SDK (Embedded / HTTP)** + +```python +result = client.snapshot.diff( + "viking://resources/my_project/guide.md", + from_ref="3f2a1b9c", + to_ref="9a0b1c2d", +) +print(result["diff_text"]) +``` + +**TypeScript SDK** + +```typescript +const result = await client.gitDiff( + "viking://resources/my_project/guide.md", + "9a0b1c2d", + "3f2a1b9c", +); +console.log(result.diff_text); +``` + +**HTTP API** + +``` +GET /api/v1/snapshot/diff?path={uri}&from={old_ref}&to={new_ref} +``` + +```bash +curl --get "http://localhost:1933/api/v1/snapshot/diff" \ + --data-urlencode "path=viking://resources/my_project/guide.md" \ + --data-urlencode "from=3f2a1b9c" \ + --data-urlencode "to=9a0b1c2d" \ + -H "X-API-Key: your-key" +``` + +**CLI** + +```bash +ov snapshot diff viking://resources/my_project/guide.md \ + --from 3f2a1b9c \ + --to 9a0b1c2d +``` + +响应包含 `path`、解析后的 `from_commit` 和 `to_commit`、`change_type`(`added`、`deleted`、`modified` 或 `unchanged`)以及 `diff_text`。 + +--- + ### restore() 把某个目录(或整棵账号树)恢复到 `source_commit` 时的状态。 diff --git a/docs/zh/api/12-user-settings.md b/docs/zh/api/12-user-settings.md new file mode 100644 index 0000000000..6c24703889 --- /dev/null +++ b/docs/zh/api/12-user-settings.md @@ -0,0 +1,89 @@ +# Agent 进化用户设置 + +用户设置用于控制后续 session commit 是否生成或更新 `trajectories` 和 `experiences`。配置按用户隔离,保存在 `viking://user//settings/user_config.json`。 + +## 生效规则 + +- `agent_evolution_enabled` 未显式配置时默认为 `false`。 +- 关闭后,session 归档、Working Memory 和其他记忆类型仍正常处理。 +- 关闭不会删除已有 trajectory 或 experience,也不影响已有 experience 的检索和读取。 +- 开启后,session 级 `memory_policy.memory_types` 仍可限制单次 commit 的记忆类型。 + +本接口不提供用户级 `memory_types`。需要限制单次 commit 时,请使用 session 的 `memory_policy.memory_types`。 + +## 读取设置 + +```http +GET /api/v1/user-settings/memory +``` + +`override` 是当前用户显式保存的值,`effective` 是合并部署默认值和内置默认值后的结果。GET 不会创建配置文件。 + +```bash +curl http://localhost:1933/api/v1/user-settings/memory \ + -H "X-API-Key: your-key" +``` + +响应示例: + +```json +{ + "status": "ok", + "result": { + "override": { + "agent_evolution_enabled": null + }, + "effective": { + "agent_evolution_enabled": false + } + } +} +``` + +Python SDK: + +```python +settings = await client.get_memory_settings() +``` + +CLI: + +```bash +ov user-settings memory +``` + +## 更新设置 + +```http +PATCH /api/v1/user-settings/memory +Content-Type: application/json +``` + +`agent_evolution_enabled` 接受布尔值、`null` 或不传。`null` 清除用户覆盖值;字段不传表示不修改。 + +```bash +curl -X PATCH http://localhost:1933/api/v1/user-settings/memory \ + -H "X-API-Key: your-key" \ + -H "Content-Type: application/json" \ + -d '{"agent_evolution_enabled": true}' +``` + +Python SDK: + +```python +await client.patch_memory_settings(agent_evolution_enabled=True) +await client.patch_memory_settings(agent_evolution_enabled=None) +``` + +CLI: + +```bash +ov user-settings set-memory --agent-evolution-enabled true +ov user-settings set-memory --agent-evolution-enabled false +ov user-settings set-memory --clear-agent-evolution-enabled +``` + +## 相关文档 + +- [会话管理](05-sessions.md) - 创建 session、配置 `memory_policy` 和执行 commit +- [检索](06-retrieval.md) - 检索已有 experience diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index 6da6ac9526..ae54f1d990 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -1412,6 +1412,9 @@ openviking add-resource ./docs --exclude "*.tmp" "add_targets": { "resource_uri": "viking://user/resources", "skill_uri": "viking://user/skills" + }, + "agent_evolution": { + "enabled": false } } } @@ -1433,12 +1436,13 @@ openviking add-resource ./docs --exclude "*.tmp" | `temp_upload.shared_prefix` | str | 分配 shared `temp_file_id` 对象时使用的 URI 前缀。 | `"viking://upload"` | | `user_config_defaults.add_targets.resource_uri` | str | `add_resource` 未传 `to` 和 `parent` 时使用的部署级默认资源添加目录。`viking://user/...` 会按请求用户解析。 | `null` | | `user_config_defaults.add_targets.skill_uri` | str | `add_skill` 未传 `target_uri` 时使用的部署级默认技能添加根目录。仅允许 `viking://user/skills` 和 `viking://agent/skills`。 | `null` | +| `user_config_defaults.agent_evolution.enabled` | bool | 用户未显式覆盖时,控制 session commit 是否生成或更新 trajectories 和 experiences 的部署级默认值。 | `null`(有效值为 `false`) | `api_key` 模式使用 API Key 认证,也是默认模式;`trusted` 模式信任上游网关或受信调用方注入的 `X-OpenViking-Account` / `X-OpenViking-User` 请求头。 在 `api_key` 模式下配置 `root_api_key` 后,服务端启用正式多租户认证,并通过 Admin API 创建工作区和用户 key。在 `trusted` 模式下,普通请求不需要先注册 user key;每个请求都会根据注入的身份头解析成 `USER`。只有在 `auth_mode = "api_key"` 且未配置 `root_api_key` 时,服务端才会进入开发模式。 -`user_config_defaults` 用于给没有个人覆盖配置的新老用户提供默认用户配置。添加操作中,显式请求目标仍然优先:`add_resource.to` / `add_resource.parent` 优先于用户默认值,`add_skill.target_uri` 优先于用户默认值。个人覆盖配置存储在 `viking://user/{user_id}/settings/user_config.json`。 +`user_config_defaults` 用于给没有个人覆盖配置的新老用户提供默认用户配置。添加操作中,显式请求目标仍然优先:`add_resource.to` / `add_resource.parent` 优先于用户默认值,`add_skill.target_uri` 优先于用户默认值。`agent_evolution.enabled` 在用户级和部署级都未设置时,有效值为 `false`。个人覆盖配置存储在 `viking://user/{user_id}/settings/user_config.json`。 支持的 add target URI: diff --git a/openviking/async_client.py b/openviking/async_client.py index 9e6f113617..f4d2afee08 100644 --- a/openviking/async_client.py +++ b/openviking/async_client.py @@ -24,6 +24,8 @@ logger = get_logger(__name__) +_UNSET = object() + if TYPE_CHECKING: from openviking.snapshot_namespace import AsyncSnapshotNamespace @@ -141,6 +143,24 @@ async def session_exists(self, session_id: str) -> bool: await self._ensure_initialized() return await self._client.session_exists(session_id) + async def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + await self._ensure_initialized() + return await self._client.get_memory_settings() + + async def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any = _UNSET, + ) -> Dict[str, Any]: + """Partially update current-user memory settings.""" + await self._ensure_initialized() + if agent_evolution_enabled is _UNSET: + return await self._client.patch_memory_settings() + return await self._client.patch_memory_settings( + agent_evolution_enabled=agent_evolution_enabled + ) + async def create_session( self, session_id: Optional[str] = None, diff --git a/openviking/client/local.py b/openviking/client/local.py index bcccbed61e..f3b42fe22b 100644 --- a/openviking/client/local.py +++ b/openviking/client/local.py @@ -32,6 +32,8 @@ from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils import run_async +_UNSET = object() + def _to_jsonable(value: Any) -> Any: """Convert internal objects into JSON-serializable values.""" @@ -122,6 +124,23 @@ async def close(self) -> None: """Close the local client.""" await self._service.close() + async def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + return await self._service.sessions.get_memory_settings(self._ctx) + + async def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any = _UNSET, + ) -> Dict[str, Any]: + """Partially update current-user memory settings.""" + if agent_evolution_enabled is _UNSET: + return await self.get_memory_settings() + return await self._service.sessions.patch_memory_settings( + self._ctx, + agent_evolution_enabled=agent_evolution_enabled, + ) + # ============= Resource Management ============= async def add_resource( @@ -1185,6 +1204,21 @@ async def git_log( """Walk back along parents[0] up to limit commits.""" return await self._service.fs.log(branch=branch, limit=limit, paths=paths, ctx=self._ctx) + async def git_diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + return await self._service.fs.diff( + path=path, + from_ref=from_ref, + to_ref=to_ref, + ctx=self._ctx, + ) + async def git_get_ignore(self) -> str: """Return the account .ovgitignore content (empty string if absent).""" return await self._service.fs.get_gitignore(ctx=self._ctx) diff --git a/openviking/server/app.py b/openviking/server/app.py index 607befe0ab..51fa27ac7a 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -223,6 +223,10 @@ def _configure_session_runtime(service_obj) -> None: # noqa: ANN001 if callable(usage_reporter_setter): usage_reporter_setter(_get_usage_reporter()) + user_config_defaults_setter = getattr(sessions, "set_user_config_defaults", None) + if callable(user_config_defaults_setter): + user_config_defaults_setter(config.user_config_defaults) + if service is not None: _configure_session_runtime(service) diff --git a/openviking/server/config.py b/openviking/server/config.py index d158eb2d6e..ec07888150 100644 --- a/openviking/server/config.py +++ b/openviking/server/config.py @@ -81,10 +81,19 @@ def validate_skill_uri(cls, value: Optional[str]) -> Optional[str]: raise ValueError("skill_uri must be viking://user/skills or viking://agent/skills") +class AgentEvolutionConfig(BaseModel): + """Per-user Agent Evolution production switch.""" + + enabled: Optional[bool] = None + + model_config = {"extra": "forbid"} + + class UserConfig(BaseModel): """User configuration values that can be defaulted or initialized.""" add_targets: AddTargetsConfig = Field(default_factory=AddTargetsConfig) + agent_evolution: AgentEvolutionConfig = Field(default_factory=AgentEvolutionConfig) model_config = {"extra": "forbid"} diff --git a/openviking/server/routers/snapshot.py b/openviking/server/routers/snapshot.py index 6c83ea56e6..af6d10f831 100644 --- a/openviking/server/routers/snapshot.py +++ b/openviking/server/routers/snapshot.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0 """HTTP routes for git-style version control (snapshots). -Mirrors VikingFS.commit / VikingFS.restore / VikingFS.show / VikingFS.log, +Mirrors VikingFS.commit / VikingFS.restore / VikingFS.show / VikingFS.diff / VikingFS.log, which already implement the underlying semantics. """ @@ -187,6 +187,34 @@ async def show( ) +@router.get("/diff") +async def diff( + path: str = Query(..., description="Viking URI of the file to compare"), + from_ref: Optional[str] = Query( + None, + alias="from", + description="Base commit oid, branch, or tag; omit to compare against an empty file", + ), + to_ref: str = Query(..., alias="to", description="Target commit oid, branch, or tag"), + _ctx: RequestContext = Depends(get_request_context), +): + """Return a unified text diff for one path between two snapshots.""" + service = get_service() + try: + result = await service.fs.diff( + path=path, + from_ref=from_ref, + to_ref=to_ref, + ctx=_ctx, + ) + except (AGFSClientError, ValueError) as exc: + mapped = map_exception(exc) + if mapped is not None: + raise mapped from exc + raise + return Response(status="ok", result=result) + + @router.get("/ignore") async def get_ignore( _ctx: RequestContext = Depends(get_request_context), diff --git a/openviking/server/routers/user_settings.py b/openviking/server/routers/user_settings.py index d76bbc9482..c5acc70140 100644 --- a/openviking/server/routers/user_settings.py +++ b/openviking/server/routers/user_settings.py @@ -18,8 +18,12 @@ effective_resource_add_target, effective_skill_add_target, public_add_targets, + public_memory_settings, read_user_add_targets, + read_user_config, + resolve_memory_settings, write_user_add_targets, + write_user_memory_settings, ) from openviking_cli.exceptions import InvalidArgumentError, NotInitializedError @@ -33,6 +37,12 @@ class PatchAddLocationsRequest(BaseModel): skill_uri: Optional[str] = None +class PatchMemorySettingsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_evolution_enabled: Optional[bool] = None + + def _viking_fs(): viking_fs = get_service().viking_fs if viking_fs is None: @@ -64,6 +74,21 @@ async def _response(request: Request, ctx: RequestContext) -> dict[str, Any]: } +async def _memory_response(request: Request, ctx: RequestContext) -> dict[str, Any]: + viking_fs = _viking_fs() + override = await read_user_config(viking_fs, ctx) + effective = await resolve_memory_settings( + viking_fs=viking_fs, + ctx=ctx, + user_config_defaults=request.app.state.config.user_config_defaults, + user_config=override, + ) + return { + "override": public_memory_settings(override), + "effective": {"agent_evolution_enabled": effective.agent_evolution_enabled}, + } + + @router.get("/add-locations") async def get_add_locations( request: Request, @@ -115,3 +140,30 @@ async def delete_add_locations( return Response(status="ok", result=await _response(request, _ctx)).model_dump( exclude_none=True ) + + +@router.get("/memory") +async def get_memory_settings( + request: Request, + _ctx: RequestContext = Depends(get_request_context), +): + return Response(status="ok", result=await _memory_response(request, _ctx)).model_dump( + exclude_none=True + ) + + +@router.patch("/memory") +async def patch_memory_settings( + request: Request, + body: PatchMemorySettingsRequest, + _ctx: RequestContext = Depends(get_request_context), +): + await write_user_memory_settings( + _viking_fs(), + _ctx, + agent_evolution_enabled=body.agent_evolution_enabled, + agent_evolution_enabled_set="agent_evolution_enabled" in body.model_fields_set, + ) + return Response(status="ok", result=await _memory_response(request, _ctx)).model_dump( + exclude_none=True + ) diff --git a/openviking/server/user_config.py b/openviking/server/user_config.py index e384c9aff2..908446da04 100644 --- a/openviking/server/user_config.py +++ b/openviking/server/user_config.py @@ -5,17 +5,20 @@ from __future__ import annotations import json +from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Optional, TypeVar from openviking.core.namespace import canonical_user_root from openviking.core.uri_validation import validate_content_target_uri from openviking.server.config import AddTargetsConfig, UserConfig +from openviking.storage.transaction import LockContext, get_lock_manager from openviking_cli.exceptions import InvalidArgumentError, NotFoundError if TYPE_CHECKING: from openviking.server.config import ServerConfig from openviking.server.identity import RequestContext + from openviking.storage.transaction import LockHandle from openviking.storage.viking_fs import VikingFS @@ -25,10 +28,33 @@ class ResolvedAddTargets: skill_uri: Optional[str] = None +@dataclass(frozen=True) +class ResolvedMemorySettings: + agent_evolution_enabled: bool + + +_UpdateResult = TypeVar("_UpdateResult") + + def user_config_uri(ctx: RequestContext) -> str: return f"{canonical_user_root(ctx)}/settings/user_config.json" +@asynccontextmanager +async def _user_config_lock( + viking_fs: VikingFS, + uri: str, + ctx: RequestContext, +) -> AsyncIterator[Optional[LockHandle]]: + uri_to_path = getattr(viking_fs, "_uri_to_path", None) + if not callable(uri_to_path): + yield None + return + path = uri_to_path(uri, ctx=ctx) + async with LockContext(get_lock_manager(), [path], lock_mode="exact") as handle: + yield handle + + def _user_config_from_payload(payload: Any) -> UserConfig: if not isinstance(payload, dict): raise InvalidArgumentError("user config must be an object") @@ -114,6 +140,32 @@ async def read_user_config( return _user_config_from_payload(payload) +async def update_user_config( + viking_fs: VikingFS, + ctx: RequestContext, + updater: Callable[[UserConfig], _UpdateResult], +) -> _UpdateResult: + """Apply a locked read-modify-write update to the current user's config.""" + uri = user_config_uri(ctx) + async with _user_config_lock(viking_fs, uri, ctx) as handle: + current = await read_user_config(viking_fs, ctx) + before = current.model_dump() + result = updater(current) + validate_add_targets(current.add_targets, ctx=ctx, viking_fs=viking_fs) + if current.model_dump() != before: + await viking_fs.write_file( + uri, + json.dumps( + current.model_dump(exclude_none=True), + ensure_ascii=False, + sort_keys=True, + ), + ctx=ctx, + lock_handle=handle, + ) + return result + + async def write_user_config( viking_fs: VikingFS, ctx: RequestContext, @@ -121,11 +173,17 @@ async def write_user_config( ) -> ResolvedAddTargets: runtime = validate_add_targets(user_config.add_targets, ctx=ctx, viking_fs=viking_fs) uri = user_config_uri(ctx) - await viking_fs.write_file( - uri, - json.dumps(user_config.model_dump(exclude_none=True), ensure_ascii=False, sort_keys=True), - ctx=ctx, - ) + async with _user_config_lock(viking_fs, uri, ctx) as handle: + await viking_fs.write_file( + uri, + json.dumps( + user_config.model_dump(exclude_none=True), + ensure_ascii=False, + sort_keys=True, + ), + ctx=ctx, + lock_handle=handle, + ) return runtime @@ -148,13 +206,56 @@ async def write_user_add_targets( ctx: RequestContext, settings: AddTargetsConfig, ) -> ResolvedAddTargets: - user_config = await read_user_config(viking_fs, ctx) - user_config.add_targets = settings - return await write_user_config(viking_fs, ctx, user_config) + runtime = validate_add_targets(settings, ctx=ctx, viking_fs=viking_fs) + + def _set(user_config: UserConfig) -> None: + user_config.add_targets = settings + + await update_user_config(viking_fs, ctx, _set) + return runtime async def delete_user_add_targets(viking_fs: VikingFS, ctx: RequestContext) -> None: - await delete_user_config(viking_fs, ctx) + def _clear(user_config: UserConfig) -> None: + user_config.add_targets = AddTargetsConfig() + + await update_user_config(viking_fs, ctx, _clear) + + +async def write_user_memory_settings( + viking_fs: VikingFS, + ctx: RequestContext, + *, + agent_evolution_enabled: Any, + agent_evolution_enabled_set: bool, +) -> None: + def _set(user_config: UserConfig) -> None: + if agent_evolution_enabled_set: + user_config.agent_evolution.enabled = agent_evolution_enabled + + await update_user_config(viking_fs, ctx, _set) + + +async def resolve_memory_settings( + *, + viking_fs: VikingFS, + ctx: RequestContext, + user_config_defaults: Optional[UserConfig] = None, + user_config: Optional[UserConfig] = None, +) -> ResolvedMemorySettings: + current = user_config or await read_user_config(viking_fs, ctx) + defaults = user_config_defaults or UserConfig() + enabled = current.agent_evolution.enabled + if enabled is None: + enabled = defaults.agent_evolution.enabled + effective_agent_evolution_enabled = bool(enabled) if enabled is not None else False + return ResolvedMemorySettings( + agent_evolution_enabled=effective_agent_evolution_enabled, + ) + + +def public_memory_settings(user_config: UserConfig) -> dict[str, Any]: + return {"agent_evolution_enabled": user_config.agent_evolution.enabled} async def effective_resource_add_target( diff --git a/openviking/service/fs_service.py b/openviking/service/fs_service.py index b6c254e996..1ed9782beb 100644 --- a/openviking/service/fs_service.py +++ b/openviking/service/fs_service.py @@ -662,6 +662,19 @@ async def show_blob_raw( path = validate_viking_uri(path, field_name="path") return await viking_fs.show_blob_raw(target_ref, path=path, ctx=ctx) + async def diff( + self, + *, + path: str, + from_ref: Optional[str], + to_ref: str, + ctx: RequestContext, + ) -> Dict[str, Any]: + """Return a unified text diff for one path between two snapshots.""" + viking_fs = self._ensure_initialized() + path = validate_viking_uri(path, field_name="path") + return await viking_fs.diff(path=path, from_ref=from_ref, to_ref=to_ref, ctx=ctx) + async def log( self, ctx: RequestContext, diff --git a/openviking/service/session_service.py b/openviking/service/session_service.py index 867ecaf48f..1590338c19 100644 --- a/openviking/service/session_service.py +++ b/openviking/service/session_service.py @@ -9,8 +9,14 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional from openviking.core.namespace import canonical_session_uri -from openviking.server.config import ToolOutputExternalizationConfig +from openviking.server.config import ToolOutputExternalizationConfig, UserConfig from openviking.server.identity import RequestContext +from openviking.server.user_config import ( + public_memory_settings, + read_user_config, + resolve_memory_settings, + write_user_memory_settings, +) from openviking.service.task_tracker import get_task_tracker from openviking.session import Session from openviking.session.memory.memory_type_registry import MemoryTypeRegistry @@ -44,6 +50,7 @@ def __init__( self._viking_fs = viking_fs self._session_compressor = session_compressor self._tool_output_externalization_config = ToolOutputExternalizationConfig() + self._user_config_defaults = UserConfig() self._usage_reporter: Optional["UsageReporter"] = None def set_dependencies( @@ -63,6 +70,43 @@ def set_tool_output_externalization_config( """Set tool output externalization controls for newly created sessions.""" self._tool_output_externalization_config = config.model_copy(deep=True) + def set_user_config_defaults(self, config: UserConfig) -> None: + """Set deployment defaults used by newly created session objects.""" + self._user_config_defaults = config.model_copy(deep=True) + + async def get_memory_settings(self, ctx: RequestContext) -> Dict[str, Any]: + """Return current-user memory overrides and their effective values.""" + self._ensure_initialized() + override = await read_user_config(self._viking_fs, ctx) + effective = await resolve_memory_settings( + viking_fs=self._viking_fs, + ctx=ctx, + user_config_defaults=self._user_config_defaults, + user_config=override, + ) + return { + "override": public_memory_settings(override), + "effective": { + "agent_evolution_enabled": effective.agent_evolution_enabled, + }, + } + + async def patch_memory_settings( + self, + ctx: RequestContext, + *, + agent_evolution_enabled: Any, + ) -> Dict[str, Any]: + """Update the current user's Agent Evolution override.""" + self._ensure_initialized() + await write_user_memory_settings( + self._viking_fs, + ctx, + agent_evolution_enabled=agent_evolution_enabled, + agent_evolution_enabled_set=True, + ) + return await self.get_memory_settings(ctx) + def set_usage_reporter(self, usage_reporter: Optional["UsageReporter"]) -> None: """Set the usage reporter for newly created sessions.""" self._usage_reporter = usage_reporter @@ -126,6 +170,7 @@ def session( session_id=session_id, session_uri=session_uri, tool_output_externalization_config=self._tool_output_externalization_config, + user_config_defaults=self._user_config_defaults, usage_reporter=self._usage_reporter, ) diff --git a/openviking/session/compressor_v3.py b/openviking/session/compressor_v3.py index 7ddd0d702e..e5b9ca86c2 100644 --- a/openviking/session/compressor_v3.py +++ b/openviking/session/compressor_v3.py @@ -78,6 +78,9 @@ logger = get_logger(__name__) _CASES_MEMORY_TYPE = "cases" +_TRAJECTORIES_MEMORY_TYPE = "trajectories" +_EXPERIENCES_MEMORY_TYPE = "experiences" +_AGENT_MEMORY_TYPES = frozenset({_TRAJECTORIES_MEMORY_TYPE, _EXPERIENCES_MEMORY_TYPE}) _TRAINING_CASE_SPEC_PROTOCOL = "openviking.batch_train.case_spec.v1" _TRAINING_CASE_SPEC_HEADER = "# OpenViking Batch Training CaseSpec v1" _TRAINING_FAST_PATH_MEMORY_TYPES = frozenset({"cases", "trajectories", "experiences"}) @@ -289,6 +292,7 @@ async def extract_long_term_memories( latest_archive_overview: str = "", archive_uri: Optional[str] = None, allowed_memory_types: Optional[set[str]] = None, + agent_evolution_enabled: bool = True, allow_self_memory: bool = True, allowed_peer_ids: Optional[set[str]] = None, ): @@ -302,6 +306,8 @@ async def extract_long_term_memories( session_id=session_id, archive_uri=archive_uri or "", strict_extract_errors=strict_extract_errors, + agent_evolution_enabled=agent_evolution_enabled, + allowed_memory_types=allowed_memory_types, ) result = await self._extract_user_memories( @@ -316,16 +322,34 @@ async def extract_long_term_memories( allow_self_memory=allow_self_memory, allowed_peer_ids=allowed_peer_ids, ) - train_result = await self.train_from_extracted_cases( - cases=result.cases, - messages=message_list, - ctx=ctx, - case_uri_by_name=getattr(result, "case_uri_by_name", {}), - session_id=session_id, - archive_uri=archive_uri or "", - strict_extract_errors=strict_extract_errors, - collect_memory_diff=True, - ) + agent_memory_types = _allowed_agent_memory_types(allowed_memory_types) + cases_allowed = allowed_memory_types is None or _CASES_MEMORY_TYPE in allowed_memory_types + if ( + agent_evolution_enabled + and cases_allowed + and _TRAJECTORIES_MEMORY_TYPE in agent_memory_types + ): + train_result = await self.train_from_extracted_cases( + cases=result.cases, + messages=message_list, + ctx=ctx, + case_uri_by_name=getattr(result, "case_uri_by_name", {}), + session_id=session_id, + archive_uri=archive_uri or "", + strict_extract_errors=strict_extract_errors, + collect_memory_diff=True, + allowed_memory_types=agent_memory_types, + ) + else: + train_result = { + "case_count": len(result.cases), + "submitted": 0, + "reason": ( + "agent_evolution_disabled" + if not agent_evolution_enabled + else "memory_types_filtered" + ), + } await self._write_final_memory_diff( archive_uri=archive_uri or "", ctx=ctx, @@ -349,6 +373,8 @@ async def _commit_training_case_fast_path( session_id: Optional[str], archive_uri: str, strict_extract_errors: bool, + agent_evolution_enabled: bool, + allowed_memory_types: Optional[set[str]], ) -> dict[str, Any]: if ctx is None: logger.warning("No RequestContext provided, skipping training case fast path") @@ -360,16 +386,25 @@ async def _commit_training_case_fast_path( ) case_result = _applied_memory_result(case_write) contexts = _contexts_from_update_result(case_result) - train_result = await self.train_from_extracted_cases( - cases=[case], - messages=_training_messages_after_case_spec(messages), - ctx=ctx, - case_uri_by_name={case.name: _first_context_uri(contexts)}, - session_id=session_id, - archive_uri=archive_uri, - strict_extract_errors=strict_extract_errors, - collect_memory_diff=True, - ) + agent_memory_types = _allowed_agent_memory_types(allowed_memory_types) + if agent_evolution_enabled and _TRAJECTORIES_MEMORY_TYPE in agent_memory_types: + train_result = await self.train_from_extracted_cases( + cases=[case], + messages=_training_messages_after_case_spec(messages), + ctx=ctx, + case_uri_by_name={case.name: _first_context_uri(contexts)}, + session_id=session_id, + archive_uri=archive_uri, + strict_extract_errors=strict_extract_errors, + collect_memory_diff=True, + allowed_memory_types=agent_memory_types, + ) + else: + train_result = { + "case_count": 1, + "submitted": 0, + "reason": "agent_evolution_disabled", + } await self._write_final_memory_diff( archive_uri=archive_uri, ctx=ctx, @@ -487,7 +522,10 @@ async def _extract_user_memories( registry = create_default_registry() if allow_self_memory: - await registry.initialize_memory_files(ctx) + await registry.initialize_memory_files( + ctx, + allowed_memory_types=allowed_memory_types, + ) extract_context = ExtractContext(messages) isolation_handler = MemoryIsolationHandler( @@ -580,6 +618,7 @@ async def train_from_extracted_cases( archive_uri: str = "", strict_extract_errors: bool = False, collect_memory_diff: bool = False, + allowed_memory_types: Optional[set[str]] = None, ) -> dict[str, Any]: if not messages or ctx is None: return {"case_count": 0, "submitted": 0, "reason": "missing_messages_or_ctx"} @@ -587,6 +626,15 @@ async def train_from_extracted_cases( tracer.info("No commit training case memories extracted; skipping streaming train") return {"case_count": 0, "submitted": 0} + agent_memory_types = _allowed_agent_memory_types(allowed_memory_types) + if _TRAJECTORIES_MEMORY_TYPE not in agent_memory_types: + return { + "case_count": len(cases), + "submitted": 0, + "reason": "memory_types_filtered", + } + experiences_allowed = _EXPERIENCES_MEMORY_TYPE in agent_memory_types + config = get_openviking_config() skill_enabled = ( config.memory.session_skill_extraction_enabled and self.skill_processor is not None @@ -692,9 +740,11 @@ async def train_from_extracted_cases( analysis = await self.rollout_analyzer.analyze(rollout, analysis_context) # Experience path: estimate gradients, then submit to exp trainer - exp_gradients = await ExperienceGradientEstimator( - viking_fs=viking_fs, - ).estimate(analysis, exp_trainer.policy_set, gradient_context) + exp_gradients = [] + if experiences_allowed: + exp_gradients = await ExperienceGradientEstimator( + viking_fs=viking_fs, + ).estimate(analysis, exp_trainer.policy_set, gradient_context) exp_training_result = _trajectory_only_training_result( analysis=analysis, rollout=rollout, @@ -969,6 +1019,8 @@ def _training_case_from_first_message( """ if not messages or allowed_memory_types is None: return None + if not {_CASES_MEMORY_TYPE, _TRAJECTORIES_MEMORY_TYPE}.issubset(allowed_memory_types): + return None if not set(allowed_memory_types).issubset(_TRAINING_FAST_PATH_MEMORY_TYPES): return None @@ -978,6 +1030,12 @@ def _training_case_from_first_message( return _case_from_payload(payload) +def _allowed_agent_memory_types(allowed_memory_types: Optional[set[str]]) -> set[str]: + if allowed_memory_types is None: + return set(_AGENT_MEMORY_TYPES) + return set(allowed_memory_types) & _AGENT_MEMORY_TYPES + + def _training_case_spec_payload_from_message(message: Message) -> dict[str, Any] | None: text = _message_text(message).strip() if not text.startswith(_TRAINING_CASE_SPEC_HEADER): @@ -1127,7 +1185,6 @@ def _case_evidence(case: Case) -> str: return "Structured batch training CaseSpec supplied by the training pipeline." - async def _canonical_cases_from_update_result( *, operations: ResolvedOperations, diff --git a/openviking/session/session.py b/openviking/session/session.py index 5c9b6ccff1..2fff3de3df 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -17,7 +17,7 @@ from openviking.core.peer_id import normalize_peer_id, safe_peer_id from openviking.message import Message, Part from openviking.message.part import ContextPart, TextPart, ToolPart -from openviking.server.config import ToolOutputExternalizationConfig +from openviking.server.config import ToolOutputExternalizationConfig, UserConfig from openviking.server.identity import RequestContext, Role from openviking.session.memory.constants import EXECUTION_MEMORY_TYPES from openviking.session.memory_policy import MemoryPolicy @@ -37,7 +37,7 @@ from openviking.utils.model_retry import is_retryable_api_error, retry_async from openviking.utils.time_utils import get_current_timestamp from openviking.utils.token_estimation import estimate_text_tokens -from openviking_cli.exceptions import FailedPreconditionError +from openviking_cli.exceptions import FailedPreconditionError, InvalidArgumentError from openviking_cli.session.user_id import UserIdentifier from openviking_cli.utils import get_logger, run_async from openviking_cli.utils.config import get_openviking_config @@ -56,6 +56,7 @@ _MEMORY_EXTRACTION_MAX_RETRIES = 3 _MEMORY_EXTRACTION_RETRY_BASE_DELAY_SECONDS = 1.0 _MEMORY_EXTRACTION_RETRY_MAX_DELAY_SECONDS = 8.0 +_AGENT_TRAINING_REQUIRED_MEMORY_TYPES = frozenset({"cases", "trajectories"}) def _wm_debug(msg: str) -> None: @@ -76,6 +77,46 @@ def _validate_memory_policy_types(policy: MemoryPolicy) -> None: policy.validate_memory_types(_enabled_memory_types()) +def _apply_agent_evolution_setting( + policy: MemoryPolicy, + *, + agent_evolution_enabled: bool, +) -> MemoryPolicy: + if agent_evolution_enabled: + return policy + effective_types = ( + _enabled_memory_types() if policy.memory_types is None else set(policy.memory_types) + ) + effective_types -= EXECUTION_MEMORY_TYPES + return MemoryPolicy( + self_enabled=policy.self_enabled, + peer_enabled=policy.peer_enabled, + memory_types=effective_types, + working_memory_enabled=policy.working_memory_enabled, + ) + + +def _effective_memory_types(policy: MemoryPolicy) -> set[str]: + if policy.memory_types is None: + return _enabled_memory_types() + return set(policy.memory_types) + + +def _agent_memory_skip_reason( + *, + agent_evolution_enabled: bool, + effective_memory_types: set[str], + user_config_error: Optional[str] = None, +) -> Optional[str]: + if user_config_error: + return "invalid_user_config" + if not agent_evolution_enabled: + return "agent_evolution_disabled" + if not _AGENT_TRAINING_REQUIRED_MEMORY_TYPES.issubset(effective_memory_types): + return "memory_types_filtered" + return None + + def _split_policy_memory_types( memory_types: Optional[set[str]], ) -> tuple[Optional[set[str]], Optional[set[str]]]: @@ -363,6 +404,7 @@ def __init__( session_uri: Optional[str] = None, auto_commit_threshold: int = 8000, tool_output_externalization_config: Optional[ToolOutputExternalizationConfig] = None, + user_config_defaults: Optional[UserConfig] = None, usage_reporter: Optional["UsageReporter"] = None, ): self._viking_fs = viking_fs @@ -393,6 +435,11 @@ def __init__( if tool_output_externalization_config is not None else ToolOutputExternalizationConfig() ) + self._user_config_defaults = ( + user_config_defaults.model_copy(deep=True) + if user_config_defaults is not None + else UserConfig() + ) self._usage_reporter = usage_reporter logger.info(f"Session created: {self.session_id} for user {self.user}") @@ -1088,7 +1135,34 @@ async def commit_async( memory_policy if memory_policy is not None else self._meta.memory_policy ) _validate_memory_policy_types(effective_policy) + agent_evolution_enabled = False + user_config_error: Optional[str] = None + try: + from openviking.server.user_config import resolve_memory_settings + + user_settings = await resolve_memory_settings( + viking_fs=self._viking_fs, + ctx=self.ctx, + user_config_defaults=self._user_config_defaults, + ) + agent_evolution_enabled = user_settings.agent_evolution_enabled + effective_policy = _apply_agent_evolution_setting( + effective_policy, + agent_evolution_enabled=agent_evolution_enabled, + ) + except InvalidArgumentError as exc: + user_config_error = str(exc) + effective_policy = _apply_agent_evolution_setting( + effective_policy, + agent_evolution_enabled=False, + ) effective_memory_policy = effective_policy.to_dict() + effective_memory_types = sorted(_effective_memory_types(effective_policy)) + agent_memory_skip_reason = _agent_memory_skip_reason( + agent_evolution_enabled=agent_evolution_enabled, + effective_memory_types=set(effective_memory_types), + user_config_error=user_config_error, + ) logger.info( f"[TRACER] session_commit started, trace_id={trace_id}, " f"keep_recent_count={keep_recent_count}" @@ -1214,6 +1288,9 @@ async def commit_async( actor_peer_id=self.ctx.actor_peer_id, memory_policy=effective_memory_policy, usage_uris=list(dict.fromkeys(u.uri for u in usage_snapshot if u.uri)), + agent_evolution_enabled=agent_evolution_enabled, + agent_memory_skip_reason=agent_memory_skip_reason, + user_config_error=user_config_error, ) await get_queue_manager().enqueue(QueueManager.SESSION_COMMIT, queue_msg.to_dict()) await get_task_tracker().create( @@ -1292,6 +1369,21 @@ async def resume_queued_commit(self, msg: "SessionCommitMsg") -> None: ) return + # Queue messages created before user-level Agent Evolution settings + # were introduced have no settings snapshot. Preserve their historical + # behavior: execution memory remained enabled unless memory_policy + # explicitly filtered it out. + queued_policy = MemoryPolicy.from_dict(msg.memory_policy) + agent_evolution_enabled = msg.agent_evolution_enabled + if agent_evolution_enabled is None: + agent_evolution_enabled = True + agent_memory_skip_reason = msg.agent_memory_skip_reason + if agent_memory_skip_reason is None: + agent_memory_skip_reason = _agent_memory_skip_reason( + agent_evolution_enabled=agent_evolution_enabled, + effective_memory_types=_effective_memory_types(queued_policy), + ) + await self._run_memory_extraction( task_id=msg.task_id, archive_uri=msg.archive_uri, @@ -1300,6 +1392,9 @@ async def resume_queued_commit(self, msg: "SessionCommitMsg") -> None: first_message_id=archive_messages[0].id, last_message_id=archive_messages[-1].id, memory_policy=msg.memory_policy, + agent_evolution_enabled=agent_evolution_enabled, + agent_memory_skip_reason=agent_memory_skip_reason, + user_config_error=msg.user_config_error, ) async def _run_usage_reporting( @@ -1334,6 +1429,9 @@ async def _run_memory_extraction( first_message_id: str, last_message_id: str, memory_policy: Optional[Dict[str, Any]], + agent_evolution_enabled: bool, + agent_memory_skip_reason: Optional[str], + user_config_error: Optional[str], ) -> None: """Phase 2: Extract memories, write relations, enqueue — runs in background.""" from openviking.service.task_tracker import get_task_tracker @@ -1452,9 +1550,16 @@ async def _run_retryable_phase2_step( allowed_peer_ids = extraction_scope.allowed_peer_ids session_skill_extraction_enabled = extraction_scope.include_session_skills memory_type_filter = extraction_scope.memory_types - long_term_memory_types, execution_memory_types = _split_policy_memory_types( - memory_type_filter + has_execution_memory = hasattr( + self._session_compressor, "extract_execution_memories" ) + if has_execution_memory: + long_term_memory_types, execution_memory_types = _split_policy_memory_types( + memory_type_filter + ) + else: + long_term_memory_types = memory_type_filter + execution_memory_types = set() long_term_has_work = ( memory_extraction_enabled @@ -1476,10 +1581,6 @@ async def _run_retryable_phase2_step( len(messages), ) - has_execution_memory = hasattr( - self._session_compressor, "extract_execution_memories" - ) - extraction_tasks: List[Any] = [] extraction_labels: List[str] = [] if working_memory_enabled: @@ -1504,6 +1605,7 @@ async def _run_long_term_memory_extraction() -> Any: latest_archive_overview=latest_archive_overview, archive_uri=archive_uri, allowed_memory_types=long_term_memory_types, + agent_evolution_enabled=agent_evolution_enabled, allow_self_memory=self_memory_enabled, allowed_peer_ids=allowed_peer_ids, ) @@ -1681,6 +1783,11 @@ async def _run_execution_memory_extraction() -> Any: ], "usage_events_extracted": usage_events_extracted, "active_count_updated": active_count_updated, + "effective_memory_types": sorted( + _effective_memory_types(MemoryPolicy.from_dict(memory_policy)) + ), + "agent_evolution_enabled": agent_evolution_enabled, + "agent_memory_skip_reason": agent_memory_skip_reason, "token_usage": { "llm": dict(self._meta.llm_token_usage), "embedding": dict(self._meta.embedding_token_usage), @@ -1694,6 +1801,8 @@ async def _run_execution_memory_extraction() -> Any: } if memory_diff_uri: result_payload["memory_diff_uri"] = memory_diff_uri + if user_config_error: + result_payload["user_config_error"] = user_config_error await tracker.complete( task_id, diff --git a/openviking/snapshot_namespace.py b/openviking/snapshot_namespace.py index 4c7f181a81..7198d2f7b4 100644 --- a/openviking/snapshot_namespace.py +++ b/openviking/snapshot_namespace.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict, List, Optional from openviking_cli.utils import run_async @@ -86,6 +86,21 @@ async def log( await self._client._ensure_initialized() return await self._client._client.git_log(branch=branch, limit=limit, paths=paths) + async def diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + await self._client._ensure_initialized() + return await self._client._client.git_diff( + path, + from_ref=from_ref, + to_ref=to_ref, + ) + async def get_gitignore(self) -> str: """Return the account .ovgitignore content (empty string if absent).""" await self._client._ensure_initialized() @@ -174,6 +189,18 @@ def log( ) -> List[Dict[str, Any]]: return run_async(self._ns().log(branch=branch, limit=limit, paths=paths)) + def diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + return run_async( + self._ns().diff(path, from_ref=from_ref, to_ref=to_ref) + ) + def get_gitignore(self) -> str: return run_async(self._ns().get_gitignore()) diff --git a/openviking/storage/queuefs/session_commit_msg.py b/openviking/storage/queuefs/session_commit_msg.py index 7f6ba0da1a..896c285cd6 100644 --- a/openviking/storage/queuefs/session_commit_msg.py +++ b/openviking/storage/queuefs/session_commit_msg.py @@ -16,6 +16,9 @@ class SessionCommitMsg: actor_peer_id: Optional[str] = None memory_policy: Dict[str, Any] = field(default_factory=dict) usage_uris: List[str] = field(default_factory=list) + agent_evolution_enabled: Optional[bool] = None + agent_memory_skip_reason: Optional[str] = None + user_config_error: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return asdict(self) diff --git a/openviking/storage/viking_fs.py b/openviking/storage/viking_fs.py index cbc1a1938d..6bc2ffa734 100644 --- a/openviking/storage/viking_fs.py +++ b/openviking/storage/viking_fs.py @@ -14,6 +14,7 @@ import asyncio import contextvars +import difflib import hashlib import json import os @@ -40,6 +41,7 @@ AGFSDirectoryNotEmptyError, AGFSHTTPError, AGFSInvalidOperationError, + AGFSNotFoundError, AGFSNotSupportedError, ) from openviking.resource.watch_storage import is_watch_task_control_uri @@ -4037,6 +4039,78 @@ async def show_blob_raw( ) return resp + async def diff( + self, + *, + path: str, + from_ref: Optional[str], + to_ref: str, + ctx: Optional[RequestContext] = None, + ) -> Dict[str, Any]: + """Return a unified text diff for one path between two snapshots.""" + real_ctx = self._ctx_or_default(ctx) + path = canonicalize_uri(path, ctx=real_ctx) + + from_meta: Optional[Dict[str, Any]] = None + if from_ref: + try: + from_meta = await self.show(from_ref, ctx=real_ctx) + except AGFSNotFoundError as exc: + raise NotFoundError(from_ref, "git_ref") from exc + try: + to_meta = await self.show(to_ref, ctx=real_ctx) + except AGFSNotFoundError as exc: + raise NotFoundError(to_ref, "git_ref") from exc + + async def read_optional(ref: str) -> Optional[bytes]: + try: + value = await self.show(ref, path=path, ctx=real_ctx) + except AGFSNotFoundError: + return None + if not isinstance(value, bytes): + raise TypeError(f"git_show returned unexpected blob type: {type(value).__name__}") + return value + + before_bytes = await read_optional(from_ref) if from_ref else None + after_bytes = await read_optional(to_ref) + if before_bytes is None and after_bytes is None: + raise NotFoundError(path, "git_blob") + + try: + before = before_bytes.decode("utf-8") if before_bytes is not None else "" + after = after_bytes.decode("utf-8") if after_bytes is not None else "" + except UnicodeDecodeError as exc: + raise InvalidArgumentError("snapshot diff only supports UTF-8 text files") from exc + + if before_bytes is None: + change_type = "added" + elif after_bytes is None: + change_type = "deleted" + elif before_bytes == after_bytes: + change_type = "unchanged" + else: + change_type = "modified" + + from_oid = str(from_meta.get("oid", from_ref)) if isinstance(from_meta, dict) else "" + to_oid = str(to_meta.get("oid", to_ref)) if isinstance(to_meta, dict) else to_ref + diff_lines = difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"{path}@{from_oid}", + tofile=f"{path}@{to_oid}", + ) + diff_text = "".join( + line if line.endswith("\n") else line + "\n\\ No newline at end of file\n" + for line in diff_lines + ) + return { + "path": path, + "from_commit": from_oid, + "to_commit": to_oid, + "change_type": change_type, + "diff_text": diff_text, + } + async def log( self, *, diff --git a/openviking/sync_client.py b/openviking/sync_client.py index d686865e44..ff4b859365 100644 --- a/openviking/sync_client.py +++ b/openviking/sync_client.py @@ -17,6 +17,8 @@ from openviking.utils.search_filters import SearchContextTypeInput from openviking_cli.utils import run_async +_UNSET = object() + class SyncOpenViking: """ @@ -51,6 +53,24 @@ def session_exists(self, session_id: str) -> bool: """Check whether a session exists in storage.""" return run_async(self._async_client.session_exists(session_id)) + def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + return run_async(self._async_client.get_memory_settings()) + + def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any = _UNSET, + ) -> Dict[str, Any]: + """Partially update current-user memory settings.""" + if agent_evolution_enabled is _UNSET: + return run_async(self._async_client.patch_memory_settings()) + return run_async( + self._async_client.patch_memory_settings( + agent_evolution_enabled=agent_evolution_enabled + ) + ) + def create_session( self, session_id: Optional[str] = None, diff --git a/openviking_cli/client/base.py b/openviking_cli/client/base.py index 8d3792eb30..d10a70a81c 100644 --- a/openviking_cli/client/base.py +++ b/openviking_cli/client/base.py @@ -30,6 +30,20 @@ async def close(self) -> None: """Close the client and release resources.""" ... + @abstractmethod + async def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + ... + + @abstractmethod + async def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any, + ) -> Dict[str, Any]: + """Update the current user's Agent Evolution override.""" + ... + # ============= Resource Management ============= @abstractmethod @@ -593,6 +607,16 @@ async def git_log( ) -> List[Dict[str, Any]]: """Walk back along parents[0] up to limit commits.""" + @abstractmethod + async def git_diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + @abstractmethod async def git_get_ignore(self) -> str: """Return the account .ovgitignore content (empty string if absent).""" diff --git a/sdk/python/openviking_sdk/client.py b/sdk/python/openviking_sdk/client.py index aef8efe4d6..ab8ee63cfa 100644 --- a/sdk/python/openviking_sdk/client.py +++ b/sdk/python/openviking_sdk/client.py @@ -39,6 +39,8 @@ VLMFailedError, ) +_UNSET = object() + ERROR_CODE_TO_EXCEPTION = { "INVALID_ARGUMENT": InvalidArgumentError, "INVALID_URI": InvalidURIError, @@ -1593,6 +1595,49 @@ async def git_log( ) return self._handle_response(response) + async def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + response = await self._request("GET", "/api/v1/user-settings/memory") + return self._handle_response(response) + + async def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any = _UNSET, + ) -> Dict[str, Any]: + """Partially update current-user memory settings. + + Passing ``None`` explicitly clears that user override. Omitted arguments + are not included in the PATCH body. + """ + payload: Dict[str, Any] = {} + if agent_evolution_enabled is not _UNSET: + payload["agent_evolution_enabled"] = agent_evolution_enabled + response = await self._request( + "PATCH", + "/api/v1/user-settings/memory", + json=payload, + ) + return self._handle_response(response) + + async def git_diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + params: Dict[str, Any] = {"path": path, "to": to_ref} + if from_ref is not None: + params["from"] = from_ref + response = await self._request( + "GET", + "/api/v1/snapshot/diff", + params=params, + ) + return self._handle_response(response) + async def git_get_ignore(self) -> str: """Return the account ``.ovgitignore`` content (empty string if absent).""" response = await self._request("GET", "/api/v1/snapshot/ignore") @@ -1643,6 +1688,21 @@ def session(self, session_id: Optional[str] = None, must_exist: bool = False) -> def session_exists(self, session_id: str) -> bool: return run_async(self._async_client.session_exists(session_id)) + def get_memory_settings(self) -> Dict[str, Any]: + """Return current-user memory setting overrides and effective values.""" + return run_async(self._async_client.get_memory_settings()) + + def patch_memory_settings( + self, + *, + agent_evolution_enabled: Any = _UNSET, + ) -> Dict[str, Any]: + """Partially update current-user memory settings.""" + kwargs: Dict[str, Any] = {} + if agent_evolution_enabled is not _UNSET: + kwargs["agent_evolution_enabled"] = agent_evolution_enabled + return run_async(self._async_client.patch_memory_settings(**kwargs)) + def add_resource( self, path: str, @@ -2368,6 +2428,20 @@ async def log( ) -> List[Dict[str, Any]]: return await self._client.git_log(branch=branch, limit=limit, paths=paths) + async def diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + return await self._client.git_diff( + path, + from_ref=from_ref, + to_ref=to_ref, + ) + async def get_gitignore(self) -> str: return await self._client.git_get_ignore() @@ -2446,6 +2520,18 @@ def log( ) -> List[Dict[str, Any]]: return run_async(self._ns().log(branch=branch, limit=limit, paths=paths)) + def diff( + self, + path: str, + *, + to_ref: str, + from_ref: Optional[str] = None, + ) -> Dict[str, Any]: + """Compare one file between two snapshot refs.""" + return run_async( + self._ns().diff(path, from_ref=from_ref, to_ref=to_ref) + ) + def get_gitignore(self) -> str: return run_async(self._ns().get_gitignore()) diff --git a/sdk/python/tests/test_async_client_behaviors.py b/sdk/python/tests/test_async_client_behaviors.py index 36bf86299b..fa363198b8 100644 --- a/sdk/python/tests/test_async_client_behaviors.py +++ b/sdk/python/tests/test_async_client_behaviors.py @@ -1,6 +1,6 @@ from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch import pytest from openviking_sdk import AsyncHTTPClient, SyncHTTPClient @@ -737,16 +737,12 @@ async def test_export_and_backup_ovpack_append_default_suffixes(tmp_path): async def test_backup_ovpack_preserves_existing_file_when_replace_fails(tmp_path): client = AsyncHTTPClient(url="http://localhost:1933") client._http = SimpleNamespace( - post=AsyncMock( - return_value=SimpleNamespace(is_success=True, content=b"new-backup") - ) + post=AsyncMock(return_value=SimpleNamespace(is_success=True, content=b"new-backup")) ) output = tmp_path / "backup.ovpack" output.write_bytes(b"known-good-backup") - with patch( - "openviking_sdk.client.os.replace", side_effect=OSError("replace failed") - ): + with patch("openviking_sdk.client.os.replace", side_effect=OSError("replace failed")): with pytest.raises(OSError, match="replace failed"): await client.backup_ovpack(str(output)) @@ -777,3 +773,43 @@ async def test_import_ovpack_fails_fast_when_path_is_directory(tmp_path): with pytest.raises(ValueError, match="is not a file"): await client.import_ovpack(str(pack_dir), parent="viking://resources/") + + +@pytest.mark.asyncio +async def test_async_http_client_gets_and_partially_patches_memory_settings(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response = lambda _response: {"effective": {}} + + assert await client.get_memory_settings() == {"effective": {}} + await client.patch_memory_settings(agent_evolution_enabled=True) + + assert client._request.await_args_list == [ + call("GET", "/api/v1/user-settings/memory"), + call( + "PATCH", + "/api/v1/user-settings/memory", + json={"agent_evolution_enabled": True}, + ), + ] + + with pytest.raises(TypeError): + await client.patch_memory_settings(memory_types=["profile"]) + + +def test_sync_http_client_forwards_memory_settings_calls(): + client = SyncHTTPClient(url="http://localhost:1933") + mock_patch = MagicMock(return_value=object()) + with patch.object( + client._async_client, + "patch_memory_settings", + mock_patch, + ): + with patch( + "openviking_sdk.client.run_async", + return_value={"effective": {"agent_evolution_enabled": True}}, + ): + result = client.patch_memory_settings(agent_evolution_enabled=True) + + assert result == {"effective": {"agent_evolution_enabled": True}} + mock_patch.assert_called_once_with(agent_evolution_enabled=True) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 22fb98ef54..58d7274249 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -863,6 +863,16 @@ export class OpenVikingClient { query: { branch, limit, paths }, }); } + /** Compare one file between two snapshot refs. */ + gitDiff(path: string, toRef: string, fromRef?: string): Promise { + return this.request("GET", "/api/v1/snapshot/diff", { + query: { + path: normalizeURI(path), + from: fromRef, + to: toRef, + }, + }); + } /** Return the account `.ovgitignore` content. */ async gitGetIgnore(): Promise { const result = await this.request( diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index 58acadc9bf..aca37bd24a 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -64,7 +64,9 @@ describe("OpenVikingClient", () => { }); it("sends dry_run for prune_orphans reindex requests", async () => { - const fetcher = vi.fn().mockResolvedValue(ok({ status: "completed" })); + const fetcher = vi + .fn() + .mockResolvedValue(ok({ status: "completed" })); const client = new OpenVikingClient({ baseUrl: "https://example.com", fetch: fetcher, @@ -540,7 +542,7 @@ describe("OpenVikingClient", () => { }); }); - it("supports snapshot restore, binary show, log and ignore operations", async () => { + it("supports snapshot restore, binary show, log, diff and ignore operations", async () => { const fetcher = vi .fn() .mockResolvedValueOnce(ok({ oid: "restored" })) @@ -555,6 +557,15 @@ describe("OpenVikingClient", () => { ) .mockResolvedValueOnce(ok([{ oid: "commit-1" }])) .mockResolvedValueOnce(ok([{ oid: "commit-1" }])) + .mockResolvedValueOnce( + ok({ + path: "viking://resources/a", + from_commit: "old", + to_commit: "new", + change_type: "modified", + diff_text: "@@ -1 +1 @@\n-old\n+new\n", + }), + ) .mockResolvedValueOnce(ok("*.tmp\n")) .mockResolvedValueOnce(ok(null)) .mockResolvedValueOnce(ok(null)); @@ -580,6 +591,9 @@ describe("OpenVikingClient", () => { await expect(client.gitLog("main", 20, [])).resolves.toEqual([ { oid: "commit-1" }, ]); + await expect( + client.gitDiff("viking://resources/a", "new", "old"), + ).resolves.toMatchObject({ change_type: "modified" }); await expect(client.gitGetIgnore()).resolves.toBe("*.tmp\n"); await client.gitSetIgnore("*.log\n"); await client.gitDeleteIgnore(); @@ -601,7 +615,12 @@ describe("OpenVikingClient", () => { const unfilteredLogUrl = new URL(String(fetcher.mock.calls[3]![0])); expect(unfilteredLogUrl.searchParams.get("limit")).toBe("20"); expect(unfilteredLogUrl.searchParams.getAll("paths")).toEqual([]); - expect(JSON.parse(String(fetcher.mock.calls[5]![1]?.body))).toEqual({ + const diffUrl = new URL(String(fetcher.mock.calls[4]![0])); + expect(diffUrl.pathname).toBe("/api/v1/snapshot/diff"); + expect(diffUrl.searchParams.get("path")).toBe("viking://resources/a"); + expect(diffUrl.searchParams.get("from")).toBe("old"); + expect(diffUrl.searchParams.get("to")).toBe("new"); + expect(JSON.parse(String(fetcher.mock.calls[6]![1]?.body))).toEqual({ content: "*.log\n", }); }); diff --git a/tests/client/test_http_client_snapshot.py b/tests/client/test_http_client_snapshot.py index 8c004ed88b..74274789c2 100644 --- a/tests/client/test_http_client_snapshot.py +++ b/tests/client/test_http_client_snapshot.py @@ -1,6 +1,6 @@ """Unit tests for AsyncHTTPClient git_* methods that drive /api/v1/snapshot/*.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import pytest @@ -233,3 +233,63 @@ async def test_git_log_empty_paths_is_unfiltered(): assert call["method"] == "GET" assert call["path"] == "/api/v1/snapshot/log" assert call["params"] == {"branch": "main", "limit": 5} + + +async def test_git_diff_gets_with_refs_and_path(): + client, fake = _client_with_fake() + client._handle_response = lambda resp: { + "path": "viking://resources/a.md", + "from_commit": "old", + "to_commit": "new", + "change_type": "modified", + "diff_text": "@@ -1 +1 @@\n-old\n+new\n", + } + fake.next_response = object() + + result = await client.git_diff( + "viking://resources/a.md", + from_ref="old", + to_ref="new", + ) + + assert result["change_type"] == "modified" + call = fake.calls[-1] + assert call == { + "method": "GET", + "path": "/api/v1/snapshot/diff", + "params": { + "path": "viking://resources/a.md", + "from": "old", + "to": "new", + }, + "headers": None, + } + + +async def test_git_diff_omits_optional_from_ref(): + client, fake = _client_with_fake() + fake.next_response = object() + + await client.git_diff("viking://resources/a.md", to_ref="new") + + assert fake.calls[-1]["params"] == { + "path": "viking://resources/a.md", + "to": "new", + } + + +async def test_snapshot_namespace_exposes_diff(): + client, fake = _client_with_fake() + client._handle_response = lambda resp: {"change_type": "added", "diff_text": "+new\n"} + fake.next_response = object() + + result = await client.snapshot.diff( + "viking://resources/a.md", + to_ref="new", + ) + + assert result["change_type"] == "added" + assert fake.calls[-1]["params"] == { + "path": "viking://resources/a.md", + "to": "new", + } diff --git a/tests/client/test_user_memory_settings.py b/tests/client/test_user_memory_settings.py new file mode 100644 index 0000000000..2ee1fcc5e7 --- /dev/null +++ b/tests/client/test_user_memory_settings.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from openviking.async_client import AsyncOpenViking +from openviking.client.local import LocalClient +from openviking.sync_client import SyncOpenViking + + +@pytest.mark.asyncio +async def test_local_client_exposes_memory_settings(): + client = LocalClient.__new__(LocalClient) + client._ctx = object() + client._service = SimpleNamespace( + sessions=SimpleNamespace( + get_memory_settings=AsyncMock( + return_value={ + "override": {"agent_evolution_enabled": None}, + "effective": {"agent_evolution_enabled": False}, + } + ), + patch_memory_settings=AsyncMock( + return_value={ + "override": {"agent_evolution_enabled": True}, + "effective": {"agent_evolution_enabled": True}, + } + ), + ) + ) + + settings = await client.get_memory_settings() + updated = await client.patch_memory_settings(agent_evolution_enabled=True) + + assert settings["effective"]["agent_evolution_enabled"] is False + assert updated["effective"]["agent_evolution_enabled"] is True + client._service.sessions.get_memory_settings.assert_awaited_once_with(client._ctx) + client._service.sessions.patch_memory_settings.assert_awaited_once_with( + client._ctx, + agent_evolution_enabled=True, + ) + + +@pytest.mark.asyncio +async def test_async_embedded_client_forwards_memory_settings(): + client = object.__new__(AsyncOpenViking) + client._initialized = True + client._client = SimpleNamespace( + get_memory_settings=AsyncMock(return_value={"effective": {}}), + patch_memory_settings=AsyncMock(return_value={"effective": {}}), + ) + + await client.get_memory_settings() + await client.patch_memory_settings(agent_evolution_enabled=True) + + client._client.get_memory_settings.assert_awaited_once_with() + client._client.patch_memory_settings.assert_awaited_once_with(agent_evolution_enabled=True) + + +def test_sync_embedded_client_forwards_memory_settings(monkeypatch): + client = SyncOpenViking.__new__(SyncOpenViking) + client._async_client = SimpleNamespace( + get_memory_settings=Mock(return_value=object()), + patch_memory_settings=Mock(return_value=object()), + ) + monkeypatch.setattr("openviking.sync_client.run_async", lambda value: value) + + client.get_memory_settings() + client.patch_memory_settings(agent_evolution_enabled=True) + + client._async_client.get_memory_settings.assert_called_once_with() + client._async_client.patch_memory_settings.assert_called_once_with(agent_evolution_enabled=True) diff --git a/tests/server/test_api_snapshot.py b/tests/server/test_api_snapshot.py index f51ea2dc38..5ff6a96d98 100644 --- a/tests/server/test_api_snapshot.py +++ b/tests/server/test_api_snapshot.py @@ -247,6 +247,102 @@ async def test_show_path_not_found_returns_404(client_with_resource): assert resp.json()["status"] == "error" +async def test_diff_returns_unified_diff_for_path(client_with_resource, service): + client, _ = client_with_resource + path = "viking://resources/snapshot_diff_fixture.md" + ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT) + + await service.viking_fs.write_file(path, b"# Title\n\nold line\n", ctx=ctx) + from_commit = ( + await client.post( + "/api/v1/snapshot/commit", + json={"message": "snapshot diff v1", "paths": [path]}, + ) + ).json()["result"]["commit_oid"] + + await service.viking_fs.write_file(path, b"# Title\n\nnew line\n", ctx=ctx) + to_commit = ( + await client.post( + "/api/v1/snapshot/commit", + json={"message": "snapshot diff v2", "paths": [path]}, + ) + ).json()["result"]["commit_oid"] + + response = await client.get( + "/api/v1/snapshot/diff", + params={"path": path, "from": from_commit, "to": to_commit}, + ) + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["path"] == path + assert result["from_commit"] == from_commit + assert result["to_commit"] == to_commit + assert result["change_type"] == "modified" + assert "-old line" in result["diff_text"] + assert "+new line" in result["diff_text"] + + +async def test_diff_supports_first_version_without_from_commit(client_with_resource, service): + client, _ = client_with_resource + path = "viking://resources/snapshot_diff_added_fixture.md" + ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT) + + await service.viking_fs.write_file(path, b"first version\n", ctx=ctx) + to_commit = ( + await client.post( + "/api/v1/snapshot/commit", + json={"message": "snapshot diff added", "paths": [path]}, + ) + ).json()["result"]["commit_oid"] + + response = await client.get( + "/api/v1/snapshot/diff", + params={"path": path, "to": to_commit}, + ) + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["from_commit"] == "" + assert result["to_commit"] == to_commit + assert result["change_type"] == "added" + assert "+first version" in result["diff_text"] + + +async def test_diff_reports_missing_final_newline(client_with_resource, service): + client, _ = client_with_resource + path = "viking://resources/snapshot_diff_newline_fixture.md" + ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT) + + await service.viking_fs.write_file(path, b"same line\n", ctx=ctx) + from_commit = ( + await client.post( + "/api/v1/snapshot/commit", + json={"message": "snapshot diff newline v1", "paths": [path]}, + ) + ).json()["result"]["commit_oid"] + + await service.viking_fs.write_file(path, b"same line", ctx=ctx) + to_commit = ( + await client.post( + "/api/v1/snapshot/commit", + json={"message": "snapshot diff newline v2", "paths": [path]}, + ) + ).json()["result"]["commit_oid"] + + response = await client.get( + "/api/v1/snapshot/diff", + params={"path": path, "from": from_commit, "to": to_commit}, + ) + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["change_type"] == "modified" + assert "-same line" in result["diff_text"] + assert "+same line" in result["diff_text"] + assert "\\ No newline at end of file" in result["diff_text"] + + # --------------------------------------------------------------------------- # restore (apply) — forward-commit chain + reindex hook + concurrent-commit 409 # --------------------------------------------------------------------------- diff --git a/tests/server/test_user_memory_settings.py b/tests/server/test_user_memory_settings.py new file mode 100644 index 0000000000..5f39471376 --- /dev/null +++ b/tests/server/test_user_memory_settings.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 + +import httpx + +from openviking.server.config import UserConfig +from openviking.server.identity import RequestContext, Role +from openviking.server.user_config import read_user_config +from openviking_cli.session.user_id import UserIdentifier + + +def _ctx() -> RequestContext: + return RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT) + + +async def test_memory_settings_default_disables_agent_evolution( + client: httpx.AsyncClient, +): + response = await client.get("/api/v1/user-settings/memory") + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["override"] == {"agent_evolution_enabled": None} + assert result["effective"] == {"agent_evolution_enabled": False} + + +async def test_memory_settings_patch_is_partial_and_preserves_add_targets( + service, + client: httpx.AsyncClient, +): + response = await client.patch( + "/api/v1/user-settings/add-locations", + json={"skill_uri": "viking://user/skills"}, + ) + assert response.status_code == 200, response.text + + response = await client.patch( + "/api/v1/user-settings/memory", + json={"agent_evolution_enabled": True}, + ) + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["effective"] == {"agent_evolution_enabled": True} + stored = await read_user_config(service.viking_fs, _ctx()) + assert stored.add_targets.skill_uri == "viking://user/skills" + assert stored.agent_evolution.enabled is True + + +async def test_memory_settings_rejects_removed_memory_types_field(client: httpx.AsyncClient): + response = await client.patch( + "/api/v1/user-settings/memory", + json={"memory_types": ["profile"]}, + ) + + assert response.status_code == 400 + assert "memory_types" in response.text + + +async def test_memory_settings_null_clears_override_and_uses_server_default( + app, + client: httpx.AsyncClient, +): + app.state.config.user_config_defaults = UserConfig.model_validate( + {"agent_evolution": {"enabled": True}} + ) + response = await client.patch( + "/api/v1/user-settings/memory", + json={"agent_evolution_enabled": False}, + ) + assert response.status_code == 200, response.text + + response = await client.patch( + "/api/v1/user-settings/memory", + json={"agent_evolution_enabled": None}, + ) + + assert response.status_code == 200, response.text + result = response.json()["result"] + assert result["override"] == {"agent_evolution_enabled": None} + assert result["effective"] == {"agent_evolution_enabled": True} + + +async def test_delete_add_locations_preserves_memory_settings( + service, + client: httpx.AsyncClient, +): + assert ( + await client.patch( + "/api/v1/user-settings/add-locations", + json={"skill_uri": "viking://user/skills"}, + ) + ).status_code == 200 + assert ( + await client.patch( + "/api/v1/user-settings/memory", + json={"agent_evolution_enabled": True}, + ) + ).status_code == 200 + + response = await client.delete("/api/v1/user-settings/add-locations") + + assert response.status_code == 200, response.text + stored = await read_user_config(service.viking_fs, _ctx()) + assert stored.add_targets.skill_uri is None + assert stored.agent_evolution.enabled is True diff --git a/tests/session/test_compressor_v3.py b/tests/session/test_compressor_v3.py index 941172a6b1..4da6eddd7e 100644 --- a/tests/session/test_compressor_v3.py +++ b/tests/session/test_compressor_v3.py @@ -88,6 +88,129 @@ def test_factory_ignores_deprecated_memory_version(): ) +@pytest.mark.asyncio +async def test_v3_skips_agent_training_when_agent_evolution_is_disabled(monkeypatch): + monkeypatch.setattr( + "openviking.session.compressor_v3.get_viking_fs", + lambda: SimpleNamespace(), + ) + compressor = SessionCompressorV3(vikingdb=None) + compressor._extract_user_memories = AsyncMock( + return_value=SimpleNamespace( + contexts=[], + cases=[_training_case()], + memory_diff={"operations": {}}, + case_uri_by_name={}, + ) + ) + compressor.train_from_extracted_cases = AsyncMock() + compressor._write_final_memory_diff = AsyncMock() + + await compressor.extract_long_term_memories( + messages=_messages(), + ctx=_ctx(), + allowed_memory_types={"cases", "profile"}, + agent_evolution_enabled=False, + ) + + compressor.train_from_extracted_cases.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_v3_skips_agent_training_when_execution_memory_types_are_filtered(monkeypatch): + monkeypatch.setattr( + "openviking.session.compressor_v3.get_viking_fs", + lambda: SimpleNamespace(), + ) + compressor = SessionCompressorV3(vikingdb=None) + compressor._extract_user_memories = AsyncMock( + return_value=SimpleNamespace( + contexts=[], + cases=[_training_case()], + memory_diff={"operations": {}}, + case_uri_by_name={}, + ) + ) + compressor.train_from_extracted_cases = AsyncMock() + compressor._write_final_memory_diff = AsyncMock() + + await compressor.extract_long_term_memories( + messages=_messages(), + ctx=_ctx(), + allowed_memory_types={"cases", "profile"}, + agent_evolution_enabled=True, + ) + + compressor.train_from_extracted_cases.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_v3_passes_allowed_execution_types_to_agent_training(monkeypatch): + monkeypatch.setattr( + "openviking.session.compressor_v3.get_viking_fs", + lambda: SimpleNamespace(), + ) + compressor = SessionCompressorV3(vikingdb=None) + compressor._extract_user_memories = AsyncMock( + return_value=SimpleNamespace( + contexts=[], + cases=[_training_case()], + memory_diff={"operations": {}}, + case_uri_by_name={}, + ) + ) + compressor.train_from_extracted_cases = AsyncMock( + return_value={"case_count": 1, "submitted": 1} + ) + compressor._write_final_memory_diff = AsyncMock() + + await compressor.extract_long_term_memories( + messages=_messages(), + ctx=_ctx(), + allowed_memory_types={"cases", "trajectories"}, + agent_evolution_enabled=True, + ) + + assert compressor.train_from_extracted_cases.await_args.kwargs["allowed_memory_types"] == { + "trajectories" + } + + +@pytest.mark.asyncio +async def test_v3_initializes_only_allowed_memory_files(monkeypatch): + initialized_with = [] + + class DummyRegistry: + async def initialize_memory_files(self, ctx, allowed_memory_types=None): + del ctx + initialized_with.append(allowed_memory_types) + + class DummyOrchestrator: + async def run(self): + return None, [] + + compressor = SessionCompressorV3(vikingdb=None) + compressor._get_or_create_react = lambda **kwargs: DummyOrchestrator() + compressor._write_final_memory_diff = AsyncMock() + monkeypatch.setattr( + "openviking.session.compressor_v3.get_viking_fs", + lambda: SimpleNamespace(), + ) + monkeypatch.setattr( + "openviking.session.compressor_v3.create_default_registry", + lambda: DummyRegistry(), + ) + + await compressor.extract_long_term_memories( + messages=_messages(), + ctx=_ctx(), + allowed_memory_types={"profile"}, + agent_evolution_enabled=False, + ) + + assert initialized_with == [{"profile"}] + + def test_experience_root_uri_requires_request_user(): with pytest.raises(ValueError, match="RequestContext.user.user_id is required"): _experience_root_uri(SimpleNamespace(user=None)) @@ -239,6 +362,67 @@ async def fake_estimate_exp_gradients(self, *args, **kwargs): assert cases[0].name == "duplicate_booking" +@pytest.mark.asyncio +async def test_train_from_extracted_cases_skips_experience_updates_when_not_allowed(monkeypatch): + analyzed = [] + + class FakeTrainer: + policy_set = ExperienceSet( + root_uri="viking://user/u/memories/experiences", + policies=[], + ) + + class FakeAnalyzer: + async def analyze(self, rollout, context): + del context + analyzed.append(rollout) + return RolloutAnalysis( + evaluation=RubricEvaluation( + passed=True, + score=1.0, + criterion_results=[], + feedback=[], + ), + trajectories=[ + Trajectory( + name="duplicate_booking", + uri="viking://user/u/memories/trajectories/t1.md", + content="trajectory content", + outcome="success", + retrieval_anchor="", + ) + ], + gradients=[], + ) + + estimate = AsyncMock(side_effect=AssertionError("experience estimation must be skipped")) + monkeypatch.setattr( + "openviking.session.compressor_v3.get_viking_fs", + lambda: SimpleNamespace(ls=AsyncMock(return_value=[])), + ) + monkeypatch.setattr( + "openviking.session.compressor_v3.get_streaming_policy_trainer", + AsyncMock(return_value=FakeTrainer()), + ) + monkeypatch.setattr( + "openviking.session.train.components.gradient_estimator.ExperienceGradientEstimator.estimate", + estimate, + ) + + compressor = SessionCompressorV3(vikingdb=None, rollout_analyzer=FakeAnalyzer()) + result = await compressor.train_from_extracted_cases( + cases=[_training_case()], + messages=_messages(), + ctx=_ctx(), + session_id="s1", + allowed_memory_types={"trajectories"}, + ) + + assert len(analyzed) == 1 + assert result["submitted"] == 1 + estimate.assert_not_awaited() + + @pytest.mark.asyncio async def test_train_from_extracted_multiple_case_memories_analyzes_bound_rollouts(monkeypatch): seen_rollouts = [] @@ -323,7 +507,8 @@ async def test_v3_extract_uses_patch_merge_without_directory_lock(monkeypatch): trained_cases = [] class DummyRegistry: - async def initialize_memory_files(self, ctx): + async def initialize_memory_files(self, ctx, allowed_memory_types=None): + del ctx, allowed_memory_types return None class DummyOrchestrator: @@ -369,7 +554,7 @@ async def fake_train_from_extracted_cases(**kwargs): contexts = await compressor.extract_long_term_memories( messages=_messages(), ctx=_ctx(), - allowed_memory_types={"cases", "profile"}, + allowed_memory_types={"cases", "profile", "trajectories", "experiences"}, ) assert len(applied_operations) == 1 @@ -406,7 +591,8 @@ def case_op(uri: str, name: str) -> ResolvedOperation: ) class DummyRegistry: - async def initialize_memory_files(self, ctx): + async def initialize_memory_files(self, ctx, allowed_memory_types=None): + del ctx, allowed_memory_types return None class DummyOrchestrator: @@ -476,7 +662,7 @@ async def fake_train_from_extracted_cases(**kwargs): contexts = await compressor.extract_long_term_memories( messages=_messages(), ctx=_ctx(), - allowed_memory_types={"cases", "profile"}, + allowed_memory_types={"cases", "profile", "trajectories", "experiences"}, ) assert [context.uri for context in contexts] == [canonical_uri] @@ -546,6 +732,7 @@ async def fake_train_from_extracted_cases(**kwargs): compressor._extract_user_memories = fail_extract_user_memories compressor._write_training_case_memory = fake_write_training_case_memory compressor.train_from_extracted_cases = fake_train_from_extracted_cases + compressor._write_final_memory_diff = AsyncMock() contexts = await compressor.extract_long_term_memories( messages=[case_spec, *rollout_messages], @@ -561,6 +748,37 @@ async def fake_train_from_extracted_cases(**kwargs): assert contexts[0].uri == "viking://user/u/memories/cases/duplicate_booking.md" +@pytest.mark.asyncio +async def test_v3_training_case_spec_fast_path_skips_agent_memory_when_evolution_disabled(): + compressor = SessionCompressorV3(vikingdb=None, rollout_analyzer=SimpleNamespace()) + trained = [] + + async def fake_write_training_case_memory(**kwargs): + result = MemoryUpdateResult() + result.add_written("viking://user/u/memories/cases/duplicate_booking.md") + return result + + async def fake_train_from_extracted_cases(**kwargs): + trained.append(kwargs) + return {"case_count": 1, "submitted": 1} + + compressor._write_training_case_memory = fake_write_training_case_memory + compressor.train_from_extracted_cases = fake_train_from_extracted_cases + compressor._write_final_memory_diff = AsyncMock() + + contexts = await compressor.extract_long_term_memories( + messages=[_case_spec_message(), *_messages()], + ctx=_ctx(), + session_id="s1", + archive_uri="viking://user/u/sessions/s1/history/archive_001", + allowed_memory_types={"cases", "trajectories", "experiences"}, + agent_evolution_enabled=False, + ) + + assert contexts[0].uri == "viking://user/u/memories/cases/duplicate_booking.md" + assert trained == [] + + @pytest.mark.asyncio async def test_v3_training_case_spec_fast_path_not_used_with_user_memory_policy(): extracted = False @@ -588,7 +806,7 @@ async def fake_train_from_extracted_cases(**kwargs): assert contexts == [] assert extracted is True - assert trained and trained[0]["messages"][0].id == "case-spec" + assert trained == [] @pytest.mark.asyncio diff --git a/tests/session/test_session_commit.py b/tests/session/test_session_commit.py index 97e8703f55..34afe86b81 100644 --- a/tests/session/test_session_commit.py +++ b/tests/session/test_session_commit.py @@ -13,6 +13,8 @@ from openviking import AsyncOpenViking from openviking.client.session import Session as ClientSession from openviking.message import TextPart +from openviking.server.config import UserConfig +from openviking.server.user_config import write_user_memory_settings from openviking.service.task_tracker import get_task_tracker from openviking.session import Session from openviking.storage.transaction import get_lock_manager @@ -79,6 +81,57 @@ async def test_commit_extracts_memories( # Wait for semantic/embedding queues await client.wait_processed(timeout=60.0) + async def test_commit_default_disables_agent_memory_but_keeps_archive( + self, session_with_messages: Session + ): + session_with_messages._user_config_defaults = UserConfig() + session_with_messages._session_compressor.extract_long_term_memories = AsyncMock( + return_value=[] + ) + + result = await session_with_messages.commit_async() + task_result = await _wait_for_task(result["task_id"]) + + assert result["archived"] is True + assert task_result["status"] == "completed" + assert task_result["result"]["agent_evolution_enabled"] is False + assert "trajectories" not in task_result["result"]["effective_memory_types"] + assert "experiences" not in task_result["result"]["effective_memory_types"] + assert task_result["result"]["agent_memory_skip_reason"] == ("agent_evolution_disabled") + call_kwargs = ( + session_with_messages._session_compressor.extract_long_term_memories.call_args.kwargs + ) + assert call_kwargs["agent_evolution_enabled"] is False + assert "trajectories" not in call_kwargs["allowed_memory_types"] + assert "experiences" not in call_kwargs["allowed_memory_types"] + + async def test_commit_reloads_user_setting_and_enables_agent_memory( + self, session_with_messages: Session + ): + session_with_messages._user_config_defaults = UserConfig() + session_with_messages._session_compressor.extract_long_term_memories = AsyncMock( + return_value=[] + ) + await write_user_memory_settings( + session_with_messages._viking_fs, + session_with_messages.ctx, + agent_evolution_enabled=True, + agent_evolution_enabled_set=True, + ) + + result = await session_with_messages.commit_async() + task_result = await _wait_for_task(result["task_id"]) + + assert task_result["status"] == "completed" + assert task_result["result"]["agent_evolution_enabled"] is True + assert "trajectories" in task_result["result"]["effective_memory_types"] + assert "experiences" in task_result["result"]["effective_memory_types"] + call_kwargs = ( + session_with_messages._session_compressor.extract_long_term_memories.call_args.kwargs + ) + assert call_kwargs["agent_evolution_enabled"] is True + assert call_kwargs["allowed_memory_types"] is None + async def test_commit_reports_session_skills_separately( self, session_with_messages: Session, monkeypatch ): @@ -120,9 +173,7 @@ async def test_commit_reports_session_skills_separately( session_with_messages._session_compressor.extract_long_term_memories.assert_not_awaited() if hasattr(session_with_messages._session_compressor, "extract_execution_memories"): session_with_messages._session_compressor.extract_execution_memories.assert_awaited_once() - call_kwargs = ( - session_with_messages._session_compressor.extract_execution_memories.call_args.kwargs - ) + call_kwargs = session_with_messages._session_compressor.extract_execution_memories.call_args.kwargs assert call_kwargs["allowed_memory_types"] == {"trajectories"} assert call_kwargs["include_session_skills"] is True @@ -184,9 +235,7 @@ async def test_commit_skips_session_skill_extraction_when_disabled( session_with_messages._session_compressor.extract_long_term_memories.assert_awaited_once() if hasattr(session_with_messages._session_compressor, "extract_execution_memories"): session_with_messages._session_compressor.extract_execution_memories.assert_awaited_once() - call_kwargs = ( - session_with_messages._session_compressor.extract_execution_memories.call_args.kwargs - ) + call_kwargs = session_with_messages._session_compressor.extract_execution_memories.call_args.kwargs assert call_kwargs["include_session_skills"] is False async def test_commit_can_skip_working_memory_summary( diff --git a/tests/unit/session/test_user_memory_policy.py b/tests/unit/session/test_user_memory_policy.py new file mode 100644 index 0000000000..9830ce7746 --- /dev/null +++ b/tests/unit/session/test_user_memory_policy.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 + +from openviking.session import session as session_module +from openviking.session.memory_policy import MemoryPolicy + + +def test_enabled_agent_evolution_preserves_session_memory_types(): + policy = MemoryPolicy.from_dict({"memory_types": ["profile", "events", "trajectories"]}) + + effective = session_module._apply_agent_evolution_setting( + policy, + agent_evolution_enabled=True, + ) + + assert effective.memory_types == {"profile", "events", "trajectories"} + assert effective.self_enabled is True + assert effective.peer_enabled is True + assert effective.working_memory_enabled is True + + +def test_disabled_agent_evolution_cannot_be_bypassed_by_session_policy(): + policy = MemoryPolicy.from_dict({"memory_types": ["profile", "trajectories", "experiences"]}) + + effective = session_module._apply_agent_evolution_setting( + policy, + agent_evolution_enabled=False, + ) + + assert effective.memory_types == {"profile"} + + +def test_disabled_agent_evolution_removes_execution_types_from_default_policy(): + effective = session_module._apply_agent_evolution_setting( + MemoryPolicy.default(), + agent_evolution_enabled=False, + ) + + assert effective.memory_types is not None + assert "profile" in effective.memory_types + assert "trajectories" not in effective.memory_types + assert "experiences" not in effective.memory_types + + +def test_agent_memory_skip_reason_requires_case_and_trajectory_types(): + assert ( + session_module._agent_memory_skip_reason( + agent_evolution_enabled=True, + effective_memory_types={"cases", "experiences"}, + ) + == "memory_types_filtered" + ) + assert ( + session_module._agent_memory_skip_reason( + agent_evolution_enabled=True, + effective_memory_types={"cases", "trajectories"}, + ) + is None + ) diff --git a/tests/unit/test_fs_service_git_forwarders.py b/tests/unit/test_fs_service_git_forwarders.py index 42903ded4b..650359b38d 100644 --- a/tests/unit/test_fs_service_git_forwarders.py +++ b/tests/unit/test_fs_service_git_forwarders.py @@ -1,6 +1,6 @@ """Tests for FSService git forwarder methods. -These tests verify FSService.{commit, restore, show, log} pass the right +These tests verify FSService.{commit, restore, show, diff, log} pass the right args to VikingFS. They don't exercise real git — that's covered by tests/agfs/test_viking_fs_git.py. """ @@ -27,6 +27,15 @@ def viking_fs_mock(): m.commit = AsyncMock(return_value={"result": "created", "commit_oid": "a" * 40, "changed": 1}) m.restore = AsyncMock(return_value={"result": "applied", "commit_oid": "b" * 40}) m.show = AsyncMock(return_value={"oid": "c" * 40, "message": "m", "parents": []}) + m.diff = AsyncMock( + return_value={ + "path": "viking://resources/a.md", + "from_commit": "", + "to_commit": "c" * 40, + "change_type": "added", + "diff_text": "+hello\n", + } + ) m.log = AsyncMock(return_value=[{"oid": "c" * 40, "message": "m"}]) return m @@ -115,6 +124,24 @@ async def test_show_with_path_validated(svc, viking_fs_mock): assert out == b"hello" +@pytest.mark.asyncio +async def test_diff_forwards_validated_path_and_refs(svc, viking_fs_mock): + ctx = _ctx() + out = await svc.diff( + path="viking://resources/a.md", + from_ref=None, + to_ref="main", + ctx=ctx, + ) + viking_fs_mock.diff.assert_awaited_once_with( + path="viking://resources/a.md", + from_ref=None, + to_ref="main", + ctx=ctx, + ) + assert out["change_type"] == "added" + + @pytest.mark.asyncio async def test_log_defaults(svc, viking_fs_mock): ctx = _ctx() @@ -162,5 +189,9 @@ async def test_methods_raise_when_not_initialized(): await svc.restore(project_dir="viking://x", source_commit="a", ctx=ctx) with pytest.raises(NotInitializedError): await svc.show("main", ctx=ctx) + with pytest.raises(NotInitializedError): + await svc.diff( + path="viking://resources/a.md", from_ref=None, to_ref="main", ctx=ctx + ) with pytest.raises(NotInitializedError): await svc.log(ctx=ctx) diff --git a/tests/unit/test_local_client_git.py b/tests/unit/test_local_client_git.py index 33ffc02c35..d5817552fb 100644 --- a/tests/unit/test_local_client_git.py +++ b/tests/unit/test_local_client_git.py @@ -1,7 +1,6 @@ """Tests for LocalClient git version control methods. -Verifies that LocalClient.{git_commit, git_restore, git_show, git_log} -forward the right kwargs to FSService.{commit, restore, show, log}. +Verifies that LocalClient git methods forward the right kwargs to FSService. """ from unittest.mock import AsyncMock, MagicMock @@ -19,6 +18,7 @@ def mock_fs(): m.restore = AsyncMock(return_value={"result": "applied", "commit_oid": "b" * 40}) m.show = AsyncMock(return_value={"oid": "c" * 40, "message": "m", "parents": []}) m.log = AsyncMock(return_value=[{"oid": "c" * 40, "message": "m"}]) + m.diff = AsyncMock(return_value={"change_type": "modified"}) return m @@ -142,3 +142,19 @@ async def test_log_overrides(local_client, mock_fs): paths=["viking://resources/a.md", "viking://resources/docs"], ctx=local_client._ctx, ) + + +@pytest.mark.asyncio +async def test_diff_forwards_kwargs(local_client, mock_fs): + out = await local_client.git_diff( + "viking://resources/a.md", + from_ref="old", + to_ref="new", + ) + mock_fs.diff.assert_awaited_once_with( + path="viking://resources/a.md", + from_ref="old", + to_ref="new", + ctx=local_client._ctx, + ) + assert out["change_type"] == "modified" diff --git a/tests/unit/test_snapshot_namespace.py b/tests/unit/test_snapshot_namespace.py index e165601981..72c265b1ad 100644 --- a/tests/unit/test_snapshot_namespace.py +++ b/tests/unit/test_snapshot_namespace.py @@ -20,6 +20,15 @@ def fake_async_client(): parent._client.git_restore = AsyncMock(return_value={"result": "applied", "commit_oid": "b" * 40}) parent._client.git_show = AsyncMock(return_value={"oid": "c" * 40, "parents": []}) parent._client.git_log = AsyncMock(return_value=[{"oid": "c" * 40}]) + parent._client.git_diff = AsyncMock( + return_value={ + "path": "viking://resources/a.md", + "from_commit": "a" * 40, + "to_commit": "b" * 40, + "change_type": "modified", + "diff_text": "@@ -1 +1 @@\n-old\n+new\n", + } + ) return parent @@ -119,6 +128,21 @@ async def test_async_log_overrides(async_ns, fake_async_client): ) +@pytest.mark.asyncio +async def test_async_diff_forwards(async_ns, fake_async_client): + out = await async_ns.diff( + "viking://resources/a.md", + from_ref="a" * 40, + to_ref="b" * 40, + ) + fake_async_client._client.git_diff.assert_awaited_once_with( + "viking://resources/a.md", + from_ref="a" * 40, + to_ref="b" * 40, + ) + assert out["change_type"] == "modified" + + @pytest.mark.asyncio async def test_async_ensures_initialized_before_every_call(async_ns, fake_async_client): await async_ns.commit(message="m") @@ -139,6 +163,7 @@ def test_sync_namespace_delegates_through_async(monkeypatch): inner_async_ns.restore = AsyncMock(return_value={"result": "applied"}) inner_async_ns.show = AsyncMock(return_value=b"blob") inner_async_ns.log = AsyncMock(return_value=[]) + inner_async_ns.diff = AsyncMock(return_value={"change_type": "modified"}) sync_parent._async_client.snapshot = inner_async_ns sync_ns = SyncSnapshotNamespace(sync_parent) @@ -156,6 +181,11 @@ def test_sync_namespace_delegates_through_async(monkeypatch): sync_ns.log(branch="dev", limit=3) inner_async_ns.log.assert_awaited_once_with(branch="dev", limit=3, paths=None) + sync_ns.diff("viking://x/a", from_ref="old", to_ref="new") + inner_async_ns.diff.assert_awaited_once_with( + "viking://x/a", from_ref="old", to_ref="new" + ) + def test_async_client_snapshot_property_is_lazy_and_cached(): """Accessing .snapshot twice returns the same instance and doesn't construct early."""