diff --git a/benchmark/RAG/src/core/vector_store.py b/benchmark/RAG/src/core/vector_store.py index 44a30a3c55..f6e1d964ea 100644 --- a/benchmark/RAG/src/core/vector_store.py +++ b/benchmark/RAG/src/core/vector_store.py @@ -27,19 +27,17 @@ def count_tokens(self, text: str) -> int: return 0 return len(self.enc.encode(str(text))) - def ingest(self, samples: List[StandardDoc], max_workers=10, monitor=None, ingest_mode="per_file") -> dict: + def ingest( + self, samples: List[StandardDoc], max_workers=10, monitor=None, ingest_mode="per_file" + ) -> dict: start_time = time.time() total_input_tokens = 0 total_output_tokens = 0 total_embedding_tokens = 0 - + if not samples: - return { - "time": time.time() - start_time, - "input_tokens": 0, - "output_tokens": 0 - } - + return {"time": time.time() - start_time, "input_tokens": 0, "output_tokens": 0} + if ingest_mode == "directory": doc_paths = [os.path.abspath(s.doc_path) for s in samples] common_ancestor = None @@ -48,9 +46,12 @@ def ingest(self, samples: List[StandardDoc], max_workers=10, monitor=None, inges common_ancestor = os.path.commonpath(doc_paths) except ValueError: common_ancestor = None - + if common_ancestor: - result = self.client.add_resource(common_ancestor, wait=True, telemetry=True) + result = self.client.add_resource( + path=common_ancestor, + options={"wait": True, "telemetry": True}, + ) telemetry = result.get("telemetry", {}) summary = telemetry.get("summary", {}) tokens = summary.get("tokens", {}) @@ -61,7 +62,10 @@ def ingest(self, samples: List[StandardDoc], max_workers=10, monitor=None, inges total_embedding_tokens = embedding_tokens.get("total", 0) else: for sample in samples: - result = self.client.add_resource(sample.doc_path, wait=True, telemetry=True) + result = self.client.add_resource( + path=sample.doc_path, + options={"wait": True, "telemetry": True}, + ) telemetry = result.get("telemetry", {}) summary = telemetry.get("summary", {}) tokens = summary.get("tokens", {}) @@ -72,7 +76,10 @@ def ingest(self, samples: List[StandardDoc], max_workers=10, monitor=None, inges total_embedding_tokens += embedding_tokens.get("total", 0) else: for sample in samples: - result = self.client.add_resource(sample.doc_path, wait=True, telemetry=True) + result = self.client.add_resource( + path=sample.doc_path, + options={"wait": True, "telemetry": True}, + ) telemetry = result.get("telemetry", {}) summary = telemetry.get("summary", {}) tokens = summary.get("tokens", {}) @@ -86,12 +93,15 @@ def ingest(self, samples: List[StandardDoc], max_workers=10, monitor=None, inges "time": time.time() - start_time, "input_tokens": total_input_tokens, "output_tokens": total_output_tokens, - "embedding_tokens": total_embedding_tokens + "embedding_tokens": total_embedding_tokens, } def retrieve(self, query: str, topk: int, target_uri: str = "viking://resources"): """Execute retrieval""" - return self.client.find(query=query, limit=topk, target_uri=target_uri) + return self.client.find( + query=query, + options={"limit": topk, "target_uri": target_uri}, + ) def read_resource(self, uri: str) -> str: """Read resource content""" diff --git a/benchmark/retrieval/grep/vikingdb_bm25/effectiveness/step1_add_resource.py b/benchmark/retrieval/grep/vikingdb_bm25/effectiveness/step1_add_resource.py index caaf144d16..f3af756a3c 100644 --- a/benchmark/retrieval/grep/vikingdb_bm25/effectiveness/step1_add_resource.py +++ b/benchmark/retrieval/grep/vikingdb_bm25/effectiveness/step1_add_resource.py @@ -48,16 +48,18 @@ def main(): t0 = time.monotonic() try: try: - client.mkdir(args.parent) + client.mkdir(uri=args.parent) except OpenVikingError as exc: if exc.code != "ALREADY_EXISTS": raise result = client.add_resource( path=source, - parent=args.parent, - reason="benchmark effectiveness", - wait=True, - processing_mode="semantic_and_vectors", + options={ + "parent": args.parent, + "reason": "benchmark effectiveness", + "wait": True, + "processing_mode": "semantic_and_vectors", + }, ) elapsed = time.monotonic() - t0 root_uri = result.get("root_uri", "?") diff --git a/benchmark/retrieval/grep/vikingdb_bm25/performance/step1_add_resource.py b/benchmark/retrieval/grep/vikingdb_bm25/performance/step1_add_resource.py index 9f401d7073..750f8cd533 100644 --- a/benchmark/retrieval/grep/vikingdb_bm25/performance/step1_add_resource.py +++ b/benchmark/retrieval/grep/vikingdb_bm25/performance/step1_add_resource.py @@ -111,16 +111,18 @@ def main(): t0 = time.monotonic() try: try: - client.mkdir(parent_uri) + client.mkdir(uri=parent_uri) except OpenVikingError as exc: if exc.code != "ALREADY_EXISTS": raise result = client.add_resource( path=dir_path, - parent=parent_uri, - reason=f"benchmark perf: {rel_dir}", - wait=True, - processing_mode="vectors_only", + options={ + "parent": parent_uri, + "reason": f"benchmark perf: {rel_dir}", + "wait": True, + "processing_mode": "vectors_only", + }, ) elapsed = time.monotonic() - t0 root_uri = result.get("root_uri", "?") diff --git a/docs/en/api/02-resources.md b/docs/en/api/02-resources.md index 4319fe6c76..cec5a94c87 100644 --- a/docs/en/api/02-resources.md +++ b/docs/en/api/02-resources.md @@ -333,49 +333,57 @@ client.initialize() # Add local file result = client.add_resource( - "./documents/guide.md", - reason="User guide documentation" + path="./documents/guide.md", + options={"reason": "User guide documentation"}, ) print(f"Added: {result['root_uri']}") # Parse each document to Markdown without splitting its body result = client.add_resource( - "./documents", - args={"parse_mode": "no_split"}, + path="./documents", + options={"args": {"parse_mode": "no_split"}}, ) # Add from URL to specific location result = client.add_resource( - "https://example.com/api-docs.md", - to="viking://resources/external/api-docs.md", - reason="External API documentation" + path="https://example.com/api-docs.md", + options={ + "to": "viking://resources/external/api-docs.md", + "reason": "External API documentation", + }, ) # Recursively crawl a site (same-host BFS; depth levels, max_pages cap) result = client.add_resource( - "https://docs.openviking.ai/getting-started/01-introduction", - wait=True, - timeout=180, - args={"depth": 1, "max_pages": 10}, + path="https://docs.openviking.ai/getting-started/01-introduction", + options={ + "wait": True, + "timeout": 180, + "args": {"depth": 1, "max_pages": 10}, + }, ) # Recursive crawl with path-prefix filters, also downloading file links result = client.add_resource( - "https://docs.openviking.ai/", - args={ - "depth": 2, - "max_pages": 50, - "include_paths": ["/docs/"], - "exclude_paths": ["/changelog"], - "skip_download_links": False, + path="https://docs.openviking.ai/", + options={ + "args": { + "depth": 2, + "max_pages": 50, + "include_paths": ["/docs/"], + "exclude_paths": ["/changelog"], + "skip_download_links": False, + }, }, ) # Add to the current user's private resource root result = client.add_resource( - "./documents/guide.md", - parent="viking://user/resources/docs", - create_parent=True, + path="./documents/guide.md", + options={ + "parent": "viking://user/resources/docs", + "create_parent": True, + }, ) # Wait for processing to complete @@ -383,25 +391,29 @@ client.wait_processed() # Enable scheduled updates client.add_resource( - "./documents/guide.md", - to="viking://resources/guide.md", - watch_interval=60 # Update every 60 minutes + path="./documents/guide.md", + options={ + "to": "viking://resources/guide.md", + "watch_interval": 60, # Update every 60 minutes + }, ) # Add a Feishu document with a one-time user access token client.add_resource( - "https://example.feishu.cn/docx/doc_token", - args={"feishu_access_token": "u-..."}, + path="https://example.feishu.cn/docx/doc_token", + options={"args": {"feishu_access_token": "u-..."}}, ) # Add a Feishu document with scheduled user-token refresh client.add_resource( - "https://example.feishu.cn/docx/doc_token", - to="viking://resources/feishu/doc", - watch_interval=1440, - args={ - "feishu_access_token": "u-...", - "feishu_refresh_token": "r-...", + path="https://example.feishu.cn/docx/doc_token", + options={ + "to": "viking://resources/feishu/doc", + "watch_interval": 1440, + "args": { + "feishu_access_token": "u-...", + "feishu_refresh_token": "r-...", + }, }, ) ``` diff --git a/docs/en/api/04-skills.md b/docs/en/api/04-skills.md index fa2db26a35..01418cabc3 100644 --- a/docs/en/api/04-skills.md +++ b/docs/en/api/04-skills.md @@ -93,7 +93,7 @@ OpenViking automatically detects and converts MCP tool definitions to skill form **Conversion Example**: Input (MCP format): -```python +```json { "name": "search_web", "description": "Search the web", @@ -115,7 +115,7 @@ Input (MCP format): ``` Output (Skill format): -```python +```json { "name": "search-web", "description": "Search the web", @@ -286,7 +286,7 @@ Search the web for current information. - **limit** (integer, optional): Max results, default 10 """ } -result = client.add_skill(skill) +result = client.add_skill(data=skill) print(f"Added: {result['root_uri']}") # Approach 2: Using MCP Tool format (auto-detected and converted @@ -304,20 +304,20 @@ mcp_tool = { "required": ["expression"] } } -result = client.add_skill(mcp_tool) +result = client.add_skill(data=mcp_tool) print(f"Added: {result['uri']}") # Approach 3: Add from local SKILL.md file -result = client.add_skill("./skills/search-web/SKILL.md") +result = client.add_skill(data="./skills/search-web/SKILL.md") print(f"Added: {result['uri']}") # Approach 4: Add from directory containing SKILL.md (auxiliary files included -result = client.add_skill("./skills/code-runner/") +result = client.add_skill(data="./skills/code-runner/") print(f"Added: {result['uri']}") print(f"Auxiliary files: {result['auxiliary_files']}") # Wait for processing completion -result = client.add_skill("./skills/my-skill/", wait=True) +result = client.add_skill(data="./skills/my-skill/", options={"wait": True}) client.wait_processed() ``` @@ -473,7 +473,11 @@ curl -X GET "http://localhost:1933/api/v1/skills?node_limit=1000" \ **Python SDK** ```python -skill = client.get_skill("search-web", include_content=True, include_files=True) +skill = client.get_skill( + skill_name="search-web", + include_content=True, + include_files=True, +) print(skill["name"]) print(skill.get("content")) ``` @@ -506,7 +510,7 @@ curl -X GET "http://localhost:1933/api/v1/skills/search-web?include_content=true **Python SDK** ```python -results = client.find_skills("search the internet", limit=5) +results = client.find_skills(query="search the internet", limit=5) for skill in results["skills"]: print(skill["name"], skill["score"]) @@ -544,8 +548,12 @@ curl -X POST http://localhost:1933/api/v1/skills/find \ **Python SDK** ```python -validated = client.validate_skill({"name": "search-web", "description": "..."}) -updated = client.update_skill("search-web", "./skills/search-web", wait=True) +validated = client.validate_skill(data={"name": "search-web", "description": "..."}) +updated = client.update_skill( + skill_name="search-web", + data="./skills/search-web", + options={"wait": True}, +) ``` **TypeScript SDK** @@ -599,7 +607,7 @@ curl -X PUT http://localhost:1933/api/v1/skills/search-web \ **Python SDK** ```python -client.delete_skill("old-skill") +client.delete_skill(skill_name="old-skill") ``` **TypeScript SDK** @@ -707,14 +715,14 @@ A successful update returns the same processing result as `add_skill` with an ad skill = { "name": "search-web", "description": "Search the web for current information using Google", - ... + # Additional skill fields } # Less helpful - too vague skill = { "name": "search", "description": "Search", - ... + # Additional skill fields } ``` diff --git a/docs/en/api/05-sessions.md b/docs/en/api/05-sessions.md index 4f2b1b8539..74af52ebeb 100644 --- a/docs/en/api/05-sessions.md +++ b/docs/en/api/05-sessions.md @@ -99,25 +99,28 @@ curl -X POST http://localhost:1933/api/v1/sessions \ import openviking as ov # Use HTTP client -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Create new session (auto-generated ID) result = await client.create_session() print(f"Session ID: {result['session_id']}") # Create new session with specified ID -result = await client.create_session(session_id="my-custom-session-id") +result = await client.create_session(options={"session_id": "my-custom-session-id"}) print(f"Session ID: {result['session_id']}") # Create new session with a custom auto-commit policy result = await client.create_session( - auto_commit_policy={ - "pending_token_threshold": 8000, - "message_count_threshold": 40, - "idle_timeout_seconds": 600, - "keep_recent_count": 10, - "min_commit_interval_seconds": 0, - } + options={ + "auto_commit_policy": { + "pending_token_threshold": 8000, + "message_count_threshold": 40, + "idle_timeout_seconds": 600, + "keep_recent_count": 10, + "min_commit_interval_seconds": 0, + }, + }, ) print(result["auto_commit_policy"]) ``` @@ -202,7 +205,8 @@ curl -X GET http://localhost:1933/api/v1/sessions \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() sessions = await client.list_sessions() for s in sessions: @@ -303,16 +307,17 @@ curl -X GET http://localhost:1933/api/v1/sessions/a1b2c3d4 \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Get existing session (raises NotFoundError if not found) -info = await client.get_session("a1b2c3d4") +info = await client.get_session(session_id="a1b2c3d4") print(f"Live Messages: {info['message_count']}") print(f"Total Messages: {info.get('total_message_count', 'n/a')}") print(f"Commits: {info['commit_count']}") # Get or create session -info = await client.get_session("a1b2c3d4", auto_create=True) +info = await client.get_session(session_id="a1b2c3d4", auto_create=True) ``` **TypeScript SDK** @@ -461,11 +466,13 @@ curl -X PATCH http://localhost:1933/api/v1/sessions/a1b2c3d4/config \ ```python result = client.update_session_config( - "a1b2c3d4", - memory_extraction_config={ - "events": {"tags": ["team=search", "channel=app"]} + session_id="a1b2c3d4", + options={ + "memory_extraction_config": { + "events": {"tags": ["team=search", "channel=app"]} + }, + "auto_commit_policy": {"message_count_threshold": 25}, }, - auto_commit_policy={"message_count_threshold": 25}, ) ``` @@ -727,9 +734,10 @@ curl -X GET "http://localhost:1933/api/v1/sessions/a1b2c3d4/context?token_budget ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() -context = await client.get_session_context("a1b2c3d4", token_budget=128000) +context = await client.get_session_context(session_id="a1b2c3d4", token_budget=128000) print(context["latest_archive_overview"]) print(len(context["messages"])) ``` @@ -836,9 +844,13 @@ curl -X GET "http://localhost:1933/api/v1/sessions/a1b2c3d4/archives/archive_002 ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() -archive = await client.get_session_archive("a1b2c3d4", "archive_002") +archive = await client.get_session_archive( + session_id="a1b2c3d4", + archive_id="archive_002", +) print(archive["archive_id"]) print(archive["overview"]) print(len(archive["messages"])) @@ -950,10 +962,11 @@ curl -X DELETE http://localhost:1933/api/v1/sessions/a1b2c3d4 \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Delete session -await client.delete_session("a1b2c3d4") +await client.delete_session(session_id="a1b2c3d4") ``` **TypeScript SDK** @@ -1123,37 +1136,41 @@ curl -X POST http://localhost:1933/api/v1/sessions/a1b2c3d4/messages \ import openviking as ov from openviking.message import TextPart, ImagePart, ContextPart -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Simple mode: Add user message await client.add_message( session_id="a1b2c3d4", - role="user", - content="How do I authenticate users?" + message={"role": "user", "content": "How do I authenticate users?"}, ) # Parts mode: Add assistant message with context reference await client.add_message( session_id="a1b2c3d4", - role="assistant", - parts=[ - TextPart(text="Based on the documentation, you can configure embedding..."), - ContextPart( - uri="viking://resources/docs/auth/", - context_type="resource", - abstract="Authentication guide" - ) - ] + message={ + "role": "assistant", + "parts": [ + TextPart(text="Based on the documentation, you can configure embedding..."), + ContextPart( + uri="viking://resources/docs/auth/", + context_type="resource", + abstract="Authentication guide" + ) + ], + }, ) # Parts mode: Add user message with an image URL await client.add_message( session_id="a1b2c3d4", - role="user", - parts=[ - TextPart(text="Remember this studio layout."), - ImagePart(url="https://example.com/studio.png", detail="auto"), - ] + message={ + "role": "user", + "parts": [ + TextPart(text="Remember this studio layout."), + ImagePart(url="https://example.com/studio.png", detail="auto"), + ], + }, ) ``` @@ -1252,7 +1269,8 @@ curl -X POST http://localhost:1933/api/v1/sessions/a1b2c3d4/messages/batch \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Add messages in batch result = await client.batch_add_messages( @@ -1429,15 +1447,16 @@ curl -X GET http://localhost:1933/api/v1/tasks/{task_id} \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Commit returns immediately with task_id; summary + memory extraction runs in background -result = await client.commit_session("a1b2c3d4") +result = await client.commit_session(session_id="a1b2c3d4") print(f"Status: {result['status']}") print(f"Task ID: {result['task_id']}") # Poll background task status -task = await client.get_task(result["task_id"]) +task = await client.get_task(task_id=result["task_id"]) if task["status"] == "completed": memories = task["result"]["memories_extracted"] total = sum(memories.values()) @@ -1645,7 +1664,8 @@ import openviking as ov from openviking.message import TextPart, ContextPart # Initialize client -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # Create new session session_result = await client.create_session() @@ -1655,33 +1675,37 @@ print(f"Session created: {session_id}") # Add user message await client.add_message( session_id=session_id, - role="user", - content="How do I configure embedding?" + message={"role": "user", "content": "How do I configure embedding?"}, ) # Search with session context -results = await client.search("embedding configuration", session_id=session_id) +results = await client.search( + query="embedding configuration", + options={"session_id": session_id}, +) # Add assistant message with context reference if results.resources: await client.add_message( session_id=session_id, - role="assistant", - parts=[ - TextPart(text="Based on the documentation, you can configure embedding..."), - ContextPart( - uri=results.resources[0].uri, - context_type="resource", - abstract=results.resources[0].abstract - ) - ] + message={ + "role": "assistant", + "parts": [ + TextPart(text="Based on the documentation, you can configure embedding..."), + ContextPart( + uri=results.resources[0].uri, + context_type="resource", + abstract=results.resources[0].abstract + ) + ], + }, ) # Commit session (returns immediately; summary + memory extraction runs in background) -commit_result = await client.commit_session(session_id) +commit_result = await client.commit_session(session_id=session_id) print(f"Task ID: {commit_result['task_id']}") # Optional: poll for completion -task = await client.get_task(commit_result["task_id"]) +task = await client.get_task(task_id=commit_result["task_id"]) if task and task["status"] == "completed": memories = task["result"]["memories_extracted"] total = sum(memories.values()) @@ -1738,16 +1762,16 @@ curl -X GET http://localhost:1933/api/v1/tasks/uuid-xxx \ ```python # Commit after significant interactions -session_info = await client.get_session(session_id) +session_info = await client.get_session(session_id=session_id) if session_info["message_count"] > 10: - await client.commit_session(session_id) + await client.commit_session(session_id=session_id) ``` ### Use Session Context for Search ```python # Better search results with conversation context -results = await client.search(query, session_id=session_id) +results = await client.search(query=query, options={"session_id": session_id}) ``` --- diff --git a/docs/en/api/06-retrieval.md b/docs/en/api/06-retrieval.md index 0c1a23729e..3ce5e666bd 100644 --- a/docs/en/api/06-retrieval.md +++ b/docs/en/api/06-retrieval.md @@ -186,29 +186,31 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # Basic search -results = client.find("how to authenticate users") +results = client.find(query="how to authenticate users") # Search with filter and time range recent_emails = client.find( - "invoice", - target_uri="viking://resources/email", - since="7d", - time_field="created_at", + query="invoice", + options={ + "target_uri": "viking://resources/email", + "since": "7d", + "time_field": "created_at", + }, ) # Search only memories and resources typed_results = client.find( - "authentication", - context_type=[ContextType.MEMORY, ContextType.RESOURCE], + query="authentication", + options={"context_type": [ContextType.MEMORY, ContextType.RESOURCE]}, ) # Search by local image, bytes, data URI, HTTP URL, or viking:// URI -image_results = client.find(image="/path/to/photo.png") +image_results = client.find(query="", options={"image": "/path/to/photo.png"}) # Search by explicit retrieval tags. Multiple tags are AND-ed. tagged_results = client.find( - "rollback runbook", - tags=["env=prod", "team=search"], + query="rollback runbook", + options={"tags": ["env=prod", "team=search"]}, ) # Iterate through results @@ -225,20 +227,20 @@ for ctx in results.resources: ```python # Search only in resources results = client.find( - "authentication", - target_uri="viking://resources" + query="authentication", + options={"target_uri": "viking://resources"}, ) # Search only in user memories results = client.find( - "preferences", - target_uri="viking://user/memories" + query="preferences", + options={"target_uri": "viking://user/memories"}, ) # Search only in current-user resources results = client.find( - "private docs", - target_uri="viking://user/resources" + query="private docs", + options={"target_uri": "viking://user/resources"}, ) # Search with the peer collection filtered to one peer @@ -247,18 +249,18 @@ peer_client = ov.SyncHTTPClient( api_key="your-key", actor_peer_id="web-visitor-alice", ) -peer_results = peer_client.find("invoice follow-up") +peer_results = peer_client.find(query="invoice follow-up") # Search only in skills results = client.find( - "web search", - target_uri="viking://user/skills" + query="web search", + options={"target_uri": "viking://user/skills"}, ) # Search in specific project results = client.find( - "API endpoints", - target_uri="viking://resources/my-project" + query="API endpoints", + options={"target_uri": "viking://resources/my-project"}, ) ``` @@ -463,20 +465,29 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # Create session with conversation context -session = client.session() -session.add_message("user", [ - TextPart(text="I'm building a login page with OAuth") -]) -session.add_message("assistant", [ - TextPart(text="I can help you with OAuth implementation.") -]) +session_info = client.create_session() +session = client.session(session_id=session_info["session_id"]) +session.add_message( + message={ + "role": "user", + "parts": [TextPart(text="I'm building a login page with OAuth")], + } +) +session.add_message( + message={ + "role": "assistant", + "parts": [TextPart(text="I can help you with OAuth implementation.")], + } +) # Search understands conversation context results = client.search( - "best practices", - session=session, - context_type=ContextType.SKILL, - since="2h" + query="best practices", + options={ + "session_id": session.session_id, + "context_type": ContextType.SKILL, + "since": "2h", + }, ) for ctx in results.resources: @@ -490,7 +501,7 @@ for ctx in results.resources: # search can also be used without session # It still performs intent analysis on the query results = client.search( - "how to implement OAuth 2.0 authorization code flow" + query="how to implement OAuth 2.0 authorization code flow" ) for ctx in results.resources: @@ -500,7 +511,10 @@ for ctx in results.resources: **Image Search** ```python -results = client.search("similar poster", image="/path/to/poster.png") +results = client.search( + query="similar poster", + options={"image": "/path/to/poster.png"}, +) ``` **TypeScript SDK** @@ -827,8 +841,8 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() results = client.grep( - "viking://resources", - "authentication", + uri="viking://resources", + pattern="authentication", case_insensitive=True, node_limit=1024, ) @@ -949,13 +963,17 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # Find all markdown files (defaults to returning at most 256 matches) -results = client.glob("**/*.md", "viking://resources") +results = client.glob(pattern="**/*.md", uri="viking://resources") print(f"Found {results['count']} markdown files:") for uri in results['matches']: print(f" {uri}") # Find all Python files with a higher explicit cap -results = client.glob("**/*.py", "viking://resources", node_limit=1024) +results = client.glob( + pattern="**/*.py", + uri="viking://resources", + node_limit=1024, +) print(f"Found {results['count']} Python files") ``` @@ -1019,7 +1037,7 @@ import openviking as ov client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() -results = client.find("authentication") +results = client.find(query="authentication") for ctx in results.resources: # Start with L0 (abstract) - already in ctx.abstract @@ -1027,11 +1045,11 @@ for ctx in results.resources: if ctx.level < 2: # Get L1 (overview) for directories - overview = client.overview(ctx.uri) + overview = client.overview(uri=ctx.uri) print(f"Overview: {overview[:500]}...") else: # Load L2 (content) for files - content = client.read(ctx.uri) + content = client.read(uri=ctx.uri) print(f"File content: {content}") ``` @@ -1063,13 +1081,13 @@ import openviking as ov client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() -results = client.find("OAuth implementation") +results = client.find(query="OAuth implementation") for ctx in results.resources: print(f"Found: {ctx.uri}") # Get related resources - relations = client.relations(ctx.uri) + relations = client.relations(uri=ctx.uri) for rel in relations: print(f" Related: {rel['uri']} - {rel['reason']}") ``` @@ -1093,10 +1111,10 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # Good - specific query -results = client.find("OAuth 2.0 authorization code flow implementation") +results = client.find(query="OAuth 2.0 authorization code flow implementation") # Less effective - too broad -results = client.find("auth") +results = client.find(query="auth") ``` ### Scope Your Searches @@ -1109,8 +1127,8 @@ client.initialize() # Search in relevant scope for better results results = client.find( - "error handling", - target_uri="viking://resources/my-project" + query="error handling", + options={"target_uri": "viking://resources/my-project"}, ) ``` @@ -1124,13 +1142,20 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # For conversational search, use session -session = client.session() -session.add_message("user", [ - TextPart(text="I'm building a login page") -]) +session_info = client.create_session() +session = client.session(session_id=session_info["session_id"]) +session.add_message( + message={ + "role": "user", + "parts": [TextPart(text="I'm building a login page")], + } +) # Search understands context -results = client.search("best practices", session=session) +results = client.search( + query="best practices", + options={"session_id": session.session_id}, +) ``` ## Related Documentation diff --git a/docs/en/api/11-snapshot.md b/docs/en/api/11-snapshot.md index de8972f041..5d51c27436 100644 --- a/docs/en/api/11-snapshot.md +++ b/docs/en/api/11-snapshot.md @@ -659,11 +659,19 @@ client.initialize() root = "viking://resources/my_project" # 1. Write initial content and commit v1 -client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", mode="create", wait=True) +client.write( + uri=f"{root}/guide.md", + content="# Guide\n\nv1 content\n", + options={"mode": "create", "wait": True}, +) v1 = client.snapshot.commit(message="v1 initial import") # 2. Modify and commit v2 -client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", mode="replace", wait=True) +client.write( + uri=f"{root}/guide.md", + content="# Guide\n\nv2 content\n", + options={"mode": "replace", "wait": True}, +) v2 = client.snapshot.commit(message="v2 update") # 3. Walk history diff --git a/docs/en/api/12-content.md b/docs/en/api/12-content.md index 00b27b1b1c..1726941e0a 100644 --- a/docs/en/api/12-content.md +++ b/docs/en/api/12-content.md @@ -18,7 +18,7 @@ Read L0 abstract (~100 tokens summary). **Python SDK** ```python -abstract = client.abstract("viking://resources/docs/") +abstract = client.abstract(uri="viking://resources/docs/") print(f"Abstract: {abstract}") # Output: "Documentation for the project API, covering authentication, endpoints..." ``` @@ -84,7 +84,7 @@ Read L1 overview, applies to directories. **Python SDK** ```python -overview = client.overview("viking://resources/docs/") +overview = client.overview(uri="viking://resources/docs/") print(f"Overview:\n{overview}") ``` @@ -157,7 +157,7 @@ Read L2 full content. **Python SDK** ```python -content = client.read("viking://resources/docs/api.md") +content = client.read(uri="viking://resources/docs/api.md") print(f"Content:\n{content}") ``` @@ -235,10 +235,9 @@ Update an existing file, or create a new one when `mode="create"`, and automatic ```python result = client.write( - "viking://resources/docs/api.md", - "# Updated API\n\nFresh content.", - mode="replace", - wait=True, + uri="viking://resources/docs/api.md", + content="# Updated API\n\nFresh content.", + options={"mode": "replace", "wait": True}, ) print(result["root_uri"]) ``` @@ -476,10 +475,9 @@ Set explicit `k=v` tags used by retrieval filters. `replace` replaces existing t ```python result = client.set_tags( - "viking://resources/project/", - ["team=search", "env=prod"], - mode="replace", - recursive=True, + uri="viking://resources/project/", + tags=["team=search", "env=prod"], + options={"mode": "replace", "recursive": True}, ) ``` @@ -623,10 +621,12 @@ Subtree reindex is not transactional. Records skipped because no semantic source ```python result = client.reindex( uri="viking://resources", - mode="vectors_only", - wait=True, - tags=["team=search", "env=prod"], - tag_mode="replace", + options={ + "mode": "vectors_only", + "wait": True, + "tags": ["team=search", "env=prod"], + "tag_mode": "replace", + }, ) print(result) ``` @@ -634,8 +634,7 @@ print(result) ```python result = client.reindex( uri="viking://user/default/skills", - mode="semantic_and_vectors", - wait=False, + options={"mode": "semantic_and_vectors", "wait": False}, ) print(result["status"]) ``` @@ -643,8 +642,7 @@ print(result["status"]) ```python result = client.reindex( uri="viking://resources", - mode="prune_orphans", - dry_run=True, + options={"mode": "prune_orphans", "dry_run": True}, ) print(result["would_delete_records"]) ``` diff --git a/docs/en/api/99-api-doc-writing-guide.md b/docs/en/api/99-api-doc-writing-guide.md index 1a9f6da6bd..c450615dd5 100644 --- a/docs/en/api/99-api-doc-writing-guide.md +++ b/docs/en/api/99-api-doc-writing-guide.md @@ -49,7 +49,7 @@ Explain the purpose of this API, point to the corresponding code entry, and brie **Python SDK** -```python +```text ``` @@ -229,8 +229,8 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://localhost:1933", api_key="your-key") result = client.add_resource( - "./documents/guide.md", - reason="User guide documentation" + path="./documents/guide.md", + options={"reason": "User guide documentation"}, ) print(f"Added: {result['root_uri']}") diff --git a/docs/en/concepts/02-context-types.md b/docs/en/concepts/02-context-types.md index 85a18f965e..ff4cbf4448 100644 --- a/docs/en/concepts/02-context-types.md +++ b/docs/en/concepts/02-context-types.md @@ -32,13 +32,13 @@ Resources are external knowledge that Agents can reference. # Add resource client.add_resource( "https://docs.example.com/api.pdf", - reason="API documentation" + {"reason": "API documentation"}, ) # Search resources results = client.find( "authentication methods", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) ``` @@ -82,7 +82,7 @@ task = await client.get_task(commit["task_id"]) # Poll until task["status"] == # Search memories results = await client.find( "UI preferences", - target_uri="viking://user/memories/" + {"target_uri": "viking://user/memories/"}, ) ``` @@ -137,13 +137,13 @@ ov skills add search-web -p viking://agent/skills # Search user skills results = await client.find( "web search", - target_uri="viking://user/skills/" + {"target_uri": "viking://user/skills/"}, ) # Search global agent skills results = await client.find( "web search", - target_uri="viking://agent/skills/" + {"target_uri": "viking://agent/skills/"}, ) ``` diff --git a/docs/en/concepts/04-viking-uri.md b/docs/en/concepts/04-viking-uri.md index 28d841969d..6bfcf99807 100644 --- a/docs/en/concepts/04-viking-uri.md +++ b/docs/en/concepts/04-viking-uri.md @@ -281,31 +281,31 @@ parent = VikingURI(uri).parent.uri # viking://resources/docs # Search only in resources results = client.find( "authentication", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) # Search only in current-user resources results = client.find( "private project notes", - target_uri="viking://user/resources/" + {"target_uri": "viking://user/resources/"}, ) # Search only in user memories results = client.find( "coding preferences", - target_uri="viking://user/memories/" + {"target_uri": "viking://user/memories/"}, ) # Search only in user skills results = client.find( "web search", - target_uri="viking://user/skills/" + {"target_uri": "viking://user/skills/"}, ) # Search only in global agent skills results = client.find( "web search", - target_uri="viking://agent/skills/" + {"target_uri": "viking://agent/skills/"}, ) ``` diff --git a/docs/en/concepts/06-extraction.md b/docs/en/concepts/06-extraction.md index e256e89ed5..8342dbe8ab 100644 --- a/docs/en/concepts/06-extraction.md +++ b/docs/en/concepts/06-extraction.md @@ -165,7 +165,7 @@ This routing applies to short and long code files alike. # Add resource await client.add_resource( "/path/to/doc.pdf", - reason="API documentation" + {"reason": "API documentation"}, ) # Flow: Parser → TreeBuilder(scope=resources) → SemanticQueue diff --git a/docs/en/concepts/07-retrieval.md b/docs/en/concepts/07-retrieval.md index 8edd4d7473..4395ef7235 100644 --- a/docs/en/concepts/07-retrieval.md +++ b/docs/en/concepts/07-retrieval.md @@ -26,7 +26,7 @@ Query → Intent Analysis → Hierarchical Retrieval → Rerank → Results # find(): Simple query results = await client.find( "OAuth authentication", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) # search(): Complex task (needs session context) diff --git a/docs/en/faq/faq.md b/docs/en/faq/faq.md index 555c51c838..46dafcf8b2 100644 --- a/docs/en/faq/faq.md +++ b/docs/en/faq/faq.md @@ -169,15 +169,17 @@ Embedding, VLM, storage, and other service configuration is managed by the OpenV ```python # Add single file await client.add_resource( - "./document.pdf", - reason="Project technical documentation", # Describe resource purpose to improve retrieval quality - to="viking://resources/docs/" # Specify storage location + path="./document.pdf", + options={ + "reason": "Project technical documentation", # Describe resource purpose to improve retrieval quality + "to": "viking://resources/docs/", # Specify storage location + }, ) # Add web page await client.add_resource( - "https://example.com/api-docs", - reason="API reference documentation" + path="https://example.com/api-docs", + options={"reason": "API reference documentation"}, ) # Wait for processing to complete @@ -196,14 +198,14 @@ await client.wait_processed() ```python # find(): Simple direct semantic search results = await client.find( - "OAuth authentication flow", - target_uri="viking://resources/" + query="OAuth authentication flow", + options={"target_uri": "viking://resources/"}, ) # search(): Complex tasks requiring intent analysis results = await client.search( - "Help me implement user login functionality", - session_info=session + query="Help me implement user login functionality", + options={"session_id": session.session_id}, ) ``` @@ -220,8 +222,12 @@ Session management is a core capability of OpenViking, supporting conversation t session = client.session() # Add conversation messages -await session.add_message("user", [{"type": "text", "text": "Help me analyze performance issues in this code"}]) -await session.add_message("assistant", [{"type": "text", "text": "Let me analyze..."}]) +await session.add_message( + message={"role": "user", "parts": [{"type": "text", "text": "Help me analyze performance issues in this code"}]} +) +await session.add_message( + message={"role": "assistant", "parts": [{"type": "text", "text": "Let me analyze..."}]} +) # Mark used context (for tracking) await session.used(["viking://resources/code/main.py"]) @@ -240,16 +246,16 @@ Memories are stored in the current User or Peer namespace; there is no current w ```python # List directory contents -items = await client.ls("viking://resources/") +items = await client.ls(uri="viking://resources/") # Read full content (L2) -content = await client.read("viking://resources/doc.md") +content = await client.read(uri="viking://resources/doc.md") # Get abstract (L0) -abstract = await client.abstract("viking://resources") +abstract = await client.abstract(uri="viking://resources") # Get overview (L1) -overview = await client.overview("viking://resources") +overview = await client.overview(uri="viking://resources") ``` ## Retrieval Optimization @@ -292,7 +298,7 @@ This strategy finds semantically matching fragments while understanding the comp 1. **Didn't wait for processing to complete** ```python - await client.add_resource("./doc.pdf") + await client.add_resource(path="./doc.pdf") await client.wait_processed() # Must wait ``` @@ -317,7 +323,7 @@ This strategy finds semantically matching fragments while understanding the comp 1. **Confirm resources have been processed** ```python # Check if resources exist - items = await client.ls("viking://resources/") + items = await client.ls(uri="viking://resources/") ``` 2. **Check `target_uri` filter condition** @@ -330,7 +336,7 @@ This strategy finds semantically matching fragments while understanding the comp 4. **Check L0 abstract quality** ```python - abstract = await client.abstract("viking://resources/your-doc") + abstract = await client.abstract(uri="viking://resources/your-doc") print(abstract) # Confirm abstract accurately reflects content ``` @@ -353,7 +359,10 @@ This strategy finds semantically matching fragments while understanding the comp 4. **View extracted memories** ```python - memories = await client.find("", target_uri="viking://user/memories/") + memories = await client.find( + query="", + options={"target_uri": "viking://user/memories/"}, + ) ``` ### Performance issues diff --git a/docs/en/getting-started/02-quickstart.md b/docs/en/getting-started/02-quickstart.md index 2952f437af..f209bb781b 100644 --- a/docs/en/getting-started/02-quickstart.md +++ b/docs/en/getting-started/02-quickstart.md @@ -189,27 +189,30 @@ try: print("Wait for semantic processing...") add_result = client.add_resource( path="https://raw.githubusercontent.com/volcengine/OpenViking/refs/heads/main/README.md", - wait=True, + options={"wait": True}, ) root_uri = add_result['root_uri'] # Explore the resource tree structure - ls_result = client.ls(root_uri) + ls_result = client.ls(uri=root_uri) print(f"Directory structure:\n{ls_result}\n") # Use glob to find markdown files glob_result = client.glob(pattern="**/*.md", uri=root_uri) if glob_result['matches']: - content = client.read(glob_result['matches'][0]) + content = client.read(uri=glob_result["matches"][0]) print(f"Content preview: {content[:200]}...\n") # Get abstract and overview of the resource - abstract = client.abstract(root_uri) - overview = client.overview(root_uri) + abstract = client.abstract(uri=root_uri) + overview = client.overview(uri=root_uri) print(f"Abstract:\n{abstract}\n\nOverview:\n{overview}\n") # Perform semantic search - results = client.find("what is openviking", target_uri=root_uri) + results = client.find( + query="what is openviking", + options={"target_uri": root_uri}, + ) print("Search results:") for result in results.get("resources", []): print(f" {result['uri']} (score: {result.get('score', 0.0):.4f})") diff --git a/docs/en/getting-started/03-quickstart-server.md b/docs/en/getting-started/03-quickstart-server.md index 35cce10987..2db496570c 100644 --- a/docs/en/getting-started/03-quickstart-server.md +++ b/docs/en/getting-started/03-quickstart-server.md @@ -121,7 +121,7 @@ try: client.wait_processed() # Search - results = client.find("what is openviking", target_uri=root_uri) + results = client.find("what is openviking", {"target_uri": root_uri}) for r in results.resources: print(f" {r.uri} (score: {r.score:.4f})") diff --git a/docs/en/guides/02-volcengine-purchase-guide.md b/docs/en/guides/02-volcengine-purchase-guide.md index 24b039fdba..00ca259ea6 100644 --- a/docs/en/guides/02-volcengine-purchase-guide.md +++ b/docs/en/guides/02-volcengine-purchase-guide.md @@ -180,8 +180,8 @@ async def test(): # Test adding a simple resource result = await client.add_resource( - "https://example.com", - reason="Connection Test" + path="https://example.com", + options={"reason": "Connection Test"}, ) print(f"✓ Configuration successful: {result['root_uri']}") diff --git a/docs/en/guides/07-operation-telemetry.md b/docs/en/guides/07-operation-telemetry.md index 43d54ee960..a704ab7195 100644 --- a/docs/en/guides/07-operation-telemetry.md +++ b/docs/en/guides/07-operation-telemetry.md @@ -340,7 +340,10 @@ from openviking_sdk import AsyncHTTPClient client = AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") await client.initialize() -result = await client.find("memory dedup", telemetry=True) +result = await client.find( + query="memory dedup", + options={"telemetry": True}, +) print(result["telemetry"]["summary"]["operation"]) print(result["telemetry"]["summary"]["duration_ms"]) ``` diff --git a/docs/en/guides/08-encryption.md b/docs/en/guides/08-encryption.md index d566096e22..26ace4b58f 100644 --- a/docs/en/guides/08-encryption.md +++ b/docs/en/guides/08-encryption.md @@ -68,10 +68,13 @@ async def test(): # add_resource expects a file path or URL sample = Path("./encrypted-sample.txt") sample.write_text("Hello, encrypted world!", encoding="utf-8") - await client.add_resource(str(sample), reason="Test encryption") + await client.add_resource( + path=str(sample), + options={"reason": "Test encryption"}, + ) # Read resource (automatically decrypted) - results = await client.find("encrypted") + results = await client.find(query="encrypted") print(f"Found {len(results)} results") await client.close() diff --git a/docs/en/guides/15-snapshot.md b/docs/en/guides/15-snapshot.md index 51e07ded05..36678044c6 100644 --- a/docs/en/guides/15-snapshot.md +++ b/docs/en/guides/15-snapshot.md @@ -137,12 +137,12 @@ client.initialize() root = "viking://resources/my_project" # 1. Write initial content and commit v1 -client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", mode="create", wait=True) +client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", {"mode": "create", "wait": True}) v1 = client.snapshot.commit(message="v1 initial import") print("v1:", v1["commit_oid"]) # 2. Modify and commit v2 -client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", mode="replace", wait=True) +client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", {"mode": "replace", "wait": True}) v2 = client.snapshot.commit(message="v2 update") # 3. Walk history diff --git a/docs/images/agents/en/sdk.md b/docs/images/agents/en/sdk.md index 6bedd2d56f..f0bc3e8188 100644 --- a/docs/images/agents/en/sdk.md +++ b/docs/images/agents/en/sdk.md @@ -35,9 +35,8 @@ reason = "[TODO]your-reason" # e.g. External API documentation # Reuse the initialized client. client.add_resource( - path=file_path, - to=resource_to, - reason=reason, + file_path, + {"to": resource_to, "reason": reason}, ) ``` @@ -53,8 +52,7 @@ session = client.create_session() session_id = session["session_id"] client.add_message( session_id, - "user", - parts=[{"type": "text", "text": text}], + {"role": "user", "parts": [{"type": "text", "text": text}]}, ) result = client.commit_session(session_id) ``` diff --git a/docs/images/agents/zh/sdk.md b/docs/images/agents/zh/sdk.md index 9ab756d6a3..fdef6cda2e 100644 --- a/docs/images/agents/zh/sdk.md +++ b/docs/images/agents/zh/sdk.md @@ -33,9 +33,8 @@ reason = "[TODO]your-reason" # e.g. External API documentation # Reuse the initialized client. client.add_resource( - path=file_path, - to=resource_to, - reason=reason, + file_path, + {"to": resource_to, "reason": reason}, ) ``` @@ -50,8 +49,7 @@ session = client.create_session() session_id = session["session_id"] client.add_message( session_id, - "user", - parts=[{"type": "text", "text": text}], + {"role": "user", "parts": [{"type": "text", "text": text}]}, ) result = client.commit_session(session_id) ``` diff --git a/docs/zh/api/02-resources.md b/docs/zh/api/02-resources.md index 6eaf34ce1a..d0d3e50dac 100644 --- a/docs/zh/api/02-resources.md +++ b/docs/zh/api/02-resources.md @@ -338,49 +338,57 @@ client.initialize() ## 添加本地文件 result = client.add_resource( - "./documents/guide.md", - reason="User guide documentation" + path="./documents/guide.md", + options={"reason": "User guide documentation"}, ) print(f"Added: {result['root_uri']}") ## 正常解析并转换为 Markdown,但每个文档正文不拆分 result = client.add_resource( - "./documents", - args={"parse_mode": "no_split"}, + path="./documents", + options={"args": {"parse_mode": "no_split"}}, ) ## 从 URL 添加到指定位置 result = client.add_resource( - "https://example.com/api-docs.md", - to="viking://resources/external/api-docs.md", - reason="External API docs" + path="https://example.com/api-docs.md", + options={ + "to": "viking://resources/external/api-docs.md", + "reason": "External API docs", + }, ) ## 递归抓取网页(同域 BFS,depth 层数、max_pages 页数上限) result = client.add_resource( - "https://docs.openviking.ai/zh/getting-started/01-introduction", - wait=True, - timeout=180, - args={"depth": 1, "max_pages": 10}, + path="https://docs.openviking.ai/zh/getting-started/01-introduction", + options={ + "wait": True, + "timeout": 180, + "args": {"depth": 1, "max_pages": 10}, + }, ) ## 递归抓取并按路径前缀过滤,同时下载页面中的文件链接 result = client.add_resource( - "https://docs.openviking.ai/", - args={ - "depth": 2, - "max_pages": 50, - "include_paths": ["/zh/"], - "exclude_paths": ["/changelog"], - "skip_download_links": False, + path="https://docs.openviking.ai/", + options={ + "args": { + "depth": 2, + "max_pages": 50, + "include_paths": ["/zh/"], + "exclude_paths": ["/changelog"], + "skip_download_links": False, + }, }, ) ## 添加到当前用户私有资源根 result = client.add_resource( - "./documents/guide.md", - parent="viking://user/resources/docs", - create_parent=True, + path="./documents/guide.md", + options={ + "parent": "viking://user/resources/docs", + "create_parent": True, + }, ) ## 等待处理完成 @@ -388,25 +396,29 @@ client.wait_processed() ## 开启定时更新 client.add_resource( - "./documents/guide.md", - to="viking://resources/guide.md", - watch_interval=60 # 每60分钟更新一次 + path="./documents/guide.md", + options={ + "to": "viking://resources/guide.md", + "watch_interval": 60, # 每60分钟更新一次 + }, ) # 使用一次性用户 access token 添加飞书文档 client.add_resource( - "https://example.feishu.cn/docx/doc_token", - args={"feishu_access_token": "u-..."}, + path="https://example.feishu.cn/docx/doc_token", + options={"args": {"feishu_access_token": "u-..."}}, ) # 使用用户 token 自动刷新添加飞书文档 client.add_resource( - "https://example.feishu.cn/docx/doc_token", - to="viking://resources/feishu/doc", - watch_interval=1440, - args={ - "feishu_access_token": "u-...", - "feishu_refresh_token": "r-...", + path="https://example.feishu.cn/docx/doc_token", + options={ + "to": "viking://resources/feishu/doc", + "watch_interval": 1440, + "args": { + "feishu_access_token": "u-...", + "feishu_refresh_token": "r-...", + }, }, ) ``` diff --git a/docs/zh/api/04-skills.md b/docs/zh/api/04-skills.md index e796457933..99bccf8fe1 100644 --- a/docs/zh/api/04-skills.md +++ b/docs/zh/api/04-skills.md @@ -92,7 +92,7 @@ OpenViking 会自动检测并将 MCP Tool 定义转换为技能格式。 **转换示例**: 输入(MCP 格式): -```python +```json { "name": "search_web", "description": "Search the web", @@ -114,7 +114,7 @@ OpenViking 会自动检测并将 MCP Tool 定义转换为技能格式。 ``` 输出(技能格式): -```python +```json { "name": "search-web", "description": "Search the web", @@ -292,7 +292,7 @@ Search the web for current information. - **limit** (integer, optional): Max results, default 10 """ } -result = client.add_skill(skill) +result = client.add_skill(data=skill) print(f"Added: {result['root_uri']}") # 方式 2:使用 MCP Tool 格式(自动检测并转换) @@ -310,20 +310,20 @@ mcp_tool = { "required": ["expression"] } } -result = client.add_skill(mcp_tool) +result = client.add_skill(data=mcp_tool) print(f"Added: {result['uri']}") # 方式 3:从本地 SKILL.md 文件添加 -result = client.add_skill("./skills/search-web/SKILL.md") +result = client.add_skill(data="./skills/search-web/SKILL.md") print(f"Added: {result['uri']}") # 方式 4:从包含 SKILL.md 的目录添加(辅助文件会一并包含) -result = client.add_skill("./skills/code-runner/") +result = client.add_skill(data="./skills/code-runner/") print(f"Added: {result['uri']}") print(f"Auxiliary files: {result['auxiliary_files']}") # 等待处理完成 -result = client.add_skill("./skills/my-skill/", wait=True) +result = client.add_skill(data="./skills/my-skill/", options={"wait": True}) client.wait_processed() ``` @@ -473,7 +473,11 @@ curl -X GET "http://localhost:1933/api/v1/skills?node_limit=1000" \ **Python SDK**: ```python -skill = client.get_skill("search-web", include_content=True, include_files=True) +skill = client.get_skill( + skill_name="search-web", + include_content=True, + include_files=True, +) print(skill["name"]) print(skill.get("content")) ``` @@ -507,7 +511,7 @@ curl -X GET "http://localhost:1933/api/v1/skills/search-web?include_content=true **Python SDK**: ```python -results = client.find_skills("search the internet", limit=5) +results = client.find_skills(query="search the internet", limit=5) for skill in results["skills"]: print(skill["name"], skill["score"]) @@ -546,8 +550,12 @@ curl -X POST http://localhost:1933/api/v1/skills/find \ **Python SDK**: ```python -validated = client.validate_skill({"name": "search-web", "description": "..."}) -updated = client.update_skill("search-web", "./skills/search-web", wait=True) +validated = client.validate_skill(data={"name": "search-web", "description": "..."}) +updated = client.update_skill( + skill_name="search-web", + data="./skills/search-web", + options={"wait": True}, +) ``` **TypeScript SDK** @@ -602,7 +610,7 @@ curl -X PUT http://localhost:1933/api/v1/skills/search-web \ **Python SDK**: ```python -client.delete_skill("old-skill") +client.delete_skill(skill_name="old-skill") ``` **TypeScript SDK** @@ -710,14 +718,14 @@ curl -X DELETE "http://localhost:1933/api/v1/skills/old-skill" \ skill = { "name": "search-web", "description": "Search the web for current information using Google", - ... + # 其他技能字段 } # 不够好 - 过于模糊 skill = { "name": "search", "description": "Search", - ... + # 其他技能字段 } ``` diff --git a/docs/zh/api/05-sessions.md b/docs/zh/api/05-sessions.md index 9ceef9439b..dce4fb35f2 100644 --- a/docs/zh/api/05-sessions.md +++ b/docs/zh/api/05-sessions.md @@ -99,25 +99,28 @@ curl -X POST http://localhost:1933/api/v1/sessions \ import openviking as ov # 使用 HTTP 客户端 -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 创建新会话(自动生成 ID) result = await client.create_session() print(f"Session ID: {result['session_id']}") # 创建指定 ID 的新会话 -result = await client.create_session(session_id="my-custom-session-id") +result = await client.create_session(options={"session_id": "my-custom-session-id"}) print(f"Session ID: {result['session_id']}") # 创建带自定义自动 commit 策略的新会话 result = await client.create_session( - auto_commit_policy={ - "pending_token_threshold": 8000, - "message_count_threshold": 40, - "idle_timeout_seconds": 600, - "keep_recent_count": 10, - "min_commit_interval_seconds": 0, - } + options={ + "auto_commit_policy": { + "pending_token_threshold": 8000, + "message_count_threshold": 40, + "idle_timeout_seconds": 600, + "keep_recent_count": 10, + "min_commit_interval_seconds": 0, + }, + }, ) print(result["auto_commit_policy"]) ``` @@ -202,7 +205,8 @@ curl -X GET http://localhost:1933/api/v1/sessions \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() sessions = await client.list_sessions() for s in sessions: @@ -303,16 +307,17 @@ curl -X GET http://localhost:1933/api/v1/sessions/a1b2c3d4 \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 获取已有会话(不存在时抛 NotFoundError) -info = await client.get_session("a1b2c3d4") +info = await client.get_session(session_id="a1b2c3d4") print(f"Live Messages: {info['message_count']}") print(f"Total Messages: {info.get('total_message_count', 'n/a')}") print(f"Commits: {info['commit_count']}") # 获取或创建会话 -info = await client.get_session("a1b2c3d4", auto_create=True) +info = await client.get_session(session_id="a1b2c3d4", auto_create=True) ``` **TypeScript SDK** @@ -459,11 +464,13 @@ curl -X PATCH http://localhost:1933/api/v1/sessions/a1b2c3d4/config \ ```python result = client.update_session_config( - "a1b2c3d4", - memory_extraction_config={ - "events": {"tags": ["team=search", "channel=app"]} + session_id="a1b2c3d4", + options={ + "memory_extraction_config": { + "events": {"tags": ["team=search", "channel=app"]} + }, + "auto_commit_policy": {"message_count_threshold": 25}, }, - auto_commit_policy={"message_count_threshold": 25}, ) ``` @@ -725,9 +732,10 @@ curl -X GET "http://localhost:1933/api/v1/sessions/a1b2c3d4/context?token_budget ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() -context = await client.get_session_context("a1b2c3d4", token_budget=128000) +context = await client.get_session_context(session_id="a1b2c3d4", token_budget=128000) print(context["latest_archive_overview"]) print(len(context["messages"])) ``` @@ -834,9 +842,13 @@ curl -X GET "http://localhost:1933/api/v1/sessions/a1b2c3d4/archives/archive_002 ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() -archive = await client.get_session_archive("a1b2c3d4", "archive_002") +archive = await client.get_session_archive( + session_id="a1b2c3d4", + archive_id="archive_002", +) print(archive["archive_id"]) print(archive["overview"]) print(len(archive["messages"])) @@ -948,10 +960,11 @@ curl -X DELETE http://localhost:1933/api/v1/sessions/a1b2c3d4 \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 删除会话 -await client.delete_session("a1b2c3d4") +await client.delete_session(session_id="a1b2c3d4") ``` **TypeScript SDK** @@ -1105,27 +1118,29 @@ curl -X POST http://localhost:1933/api/v1/sessions/a1b2c3d4/messages \ import openviking as ov from openviking.message import TextPart, ContextPart -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 简单模式:添加用户消息 await client.add_message( session_id="a1b2c3d4", - role="user", - content="How do I authenticate users?" + message={"role": "user", "content": "How do I authenticate users?"}, ) # Parts 模式:添加带有上下文引用的助手消息 await client.add_message( session_id="a1b2c3d4", - role="assistant", - parts=[ - TextPart(text="Based on the documentation, you can configure embedding..."), - ContextPart( - uri="viking://resources/docs/auth/", - context_type="resource", - abstract="Authentication guide" - ) - ] + message={ + "role": "assistant", + "parts": [ + TextPart(text="Based on the documentation, you can configure embedding..."), + ContextPart( + uri="viking://resources/docs/auth/", + context_type="resource", + abstract="Authentication guide" + ) + ], + }, ) ``` @@ -1224,7 +1239,8 @@ curl -X POST http://localhost:1933/api/v1/sessions/a1b2c3d4/messages/batch \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 批量添加消息 result = await client.batch_add_messages( @@ -1401,15 +1417,16 @@ curl -X GET http://localhost:1933/api/v1/tasks/{task_id} \ ```python import openviking as ov -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # commit 立即返回 task_id,后台异步执行摘要生成和记忆提取 -result = await client.commit_session("a1b2c3d4") +result = await client.commit_session(session_id="a1b2c3d4") print(f"Status: {result['status']}") print(f"Task ID: {result['task_id']}") # 查询后台任务状态 -task = await client.get_task(result["task_id"]) +task = await client.get_task(task_id=result["task_id"]) if task["status"] == "completed": memories = task["result"]["memories_extracted"] total = sum(memories.values()) @@ -1617,7 +1634,8 @@ import openviking as ov from openviking.message import TextPart, ContextPart # 初始化客户端 -client = ov.Client(base_url="http://localhost:1933", api_key="your-key") +client = ov.AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") +await client.initialize() # 创建新会话 session_result = await client.create_session() @@ -1627,33 +1645,37 @@ print(f"Session created: {session_id}") # 添加用户消息 await client.add_message( session_id=session_id, - role="user", - content="How do I configure embedding?" + message={"role": "user", "content": "How do I configure embedding?"}, ) # 使用会话上下文进行搜索 -results = await client.search("embedding configuration", session_id=session_id) +results = await client.search( + query="embedding configuration", + options={"session_id": session_id}, +) # 添加带有上下文引用的助手回复 if results.resources: await client.add_message( session_id=session_id, - role="assistant", - parts=[ - TextPart(text="Based on the documentation, you can configure embedding..."), - ContextPart( - uri=results.resources[0].uri, - context_type="resource", - abstract=results.resources[0].abstract - ) - ] + message={ + "role": "assistant", + "parts": [ + TextPart(text="Based on the documentation, you can configure embedding..."), + ContextPart( + uri=results.resources[0].uri, + context_type="resource", + abstract=results.resources[0].abstract + ) + ], + }, ) # 提交会话(立即返回,后台执行摘要生成和记忆提取) -commit_result = await client.commit_session(session_id) +commit_result = await client.commit_session(session_id=session_id) print(f"Task ID: {commit_result['task_id']}") # 可选:等待后台任务完成 -task = await client.get_task(commit_result["task_id"]) +task = await client.get_task(task_id=commit_result["task_id"]) if task and task["status"] == "completed": memories = task["result"]["memories_extracted"] total = sum(memories.values()) @@ -1710,16 +1732,16 @@ curl -X GET http://localhost:1933/api/v1/tasks/uuid-xxx \ ```python # 在重要交互后提交 -session_info = await client.get_session(session_id) +session_info = await client.get_session(session_id=session_id) if session_info["message_count"] > 10: - await client.commit_session(session_id) + await client.commit_session(session_id=session_id) ``` ### 使用会话上下文进行搜索 ```python # 结合对话上下文可获得更好的搜索结果 -results = await client.search(query, session_id=session_id) +results = await client.search(query=query, options={"session_id": session_id}) ``` --- diff --git a/docs/zh/api/06-retrieval.md b/docs/zh/api/06-retrieval.md index af0aa28eb5..da2c3349df 100644 --- a/docs/zh/api/06-retrieval.md +++ b/docs/zh/api/06-retrieval.md @@ -187,29 +187,31 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # 基础搜索 -results = client.find("how to authenticate users") +results = client.find(query="how to authenticate users") # 带过滤和时间范围的搜索 recent_emails = client.find( - "invoice", - target_uri="viking://resources/email", - since="7d", - time_field="created_at", + query="invoice", + options={ + "target_uri": "viking://resources/email", + "since": "7d", + "time_field": "created_at", + }, ) # 仅搜索 memories 和 resources typed_results = client.find( - "authentication", - context_type=[ContextType.MEMORY, ContextType.RESOURCE], + query="authentication", + options={"context_type": [ContextType.MEMORY, ContextType.RESOURCE]}, ) # 按本地图片、bytes、data URI、HTTP URL 或 viking:// URI 搜索 -image_results = client.find(image="/path/to/photo.png") +image_results = client.find(query="", options={"image": "/path/to/photo.png"}) # 按显式检索标签搜索。多个 tags 之间是 AND 关系。 tagged_results = client.find( - "rollback runbook", - tags=["env=prod", "team=search"], + query="rollback runbook", + options={"tags": ["env=prod", "team=search"]}, ) # 遍历结果 @@ -226,20 +228,20 @@ for ctx in results.resources: ```python # 仅在资源中搜索 results = client.find( - "authentication", - target_uri="viking://resources" + query="authentication", + options={"target_uri": "viking://resources"}, ) # 仅在用户记忆中搜索 results = client.find( - "preferences", - target_uri="viking://user/memories" + query="preferences", + options={"target_uri": "viking://user/memories"}, ) # 仅在当前用户资源中搜索 results = client.find( - "private docs", - target_uri="viking://user/resources" + query="private docs", + options={"target_uri": "viking://user/resources"}, ) # 检索时把 peer 集合过滤到一个 peer @@ -248,18 +250,18 @@ peer_client = ov.SyncHTTPClient( api_key="your-key", actor_peer_id="web-visitor-alice", ) -peer_results = peer_client.find("invoice follow-up") +peer_results = peer_client.find(query="invoice follow-up") # 仅在技能中搜索 results = client.find( - "web search", - target_uri="viking://user/skills" + query="web search", + options={"target_uri": "viking://user/skills"}, ) # 在特定项目中搜索 results = client.find( - "API endpoints", - target_uri="viking://resources/my-project" + query="API endpoints", + options={"target_uri": "viking://resources/my-project"}, ) ``` @@ -465,20 +467,29 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # 创建带对话上下文的会话 -session = client.session() -session.add_message("user", [ - TextPart(text="I'm building a login page with OAuth") -]) -session.add_message("assistant", [ - TextPart(text="I can help you with OAuth implementation.") -]) +session_info = client.create_session() +session = client.session(session_id=session_info["session_id"]) +session.add_message( + message={ + "role": "user", + "parts": [TextPart(text="I'm building a login page with OAuth")], + } +) +session.add_message( + message={ + "role": "assistant", + "parts": [TextPart(text="I can help you with OAuth implementation.")], + } +) # 搜索能够理解对话上下文 results = client.search( - "best practices", - session=session, - context_type=ContextType.SKILL, - since="2h" + query="best practices", + options={ + "session_id": session.session_id, + "context_type": ContextType.SKILL, + "since": "2h", + }, ) for ctx in results.resources: @@ -492,7 +503,7 @@ for ctx in results.resources: # search 也可以在没有会话的情况下使用 # 它仍然会对查询进行意图分析 results = client.search( - "how to implement OAuth 2.0 authorization code flow" + query="how to implement OAuth 2.0 authorization code flow" ) for ctx in results.resources: @@ -502,7 +513,10 @@ for ctx in results.resources: **图片搜索** ```python -results = client.search("similar poster", image="/path/to/poster.png") +results = client.search( + query="similar poster", + options={"image": "/path/to/poster.png"}, +) ``` **TypeScript SDK** @@ -828,8 +842,8 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() results = client.grep( - "viking://resources", - "authentication", + uri="viking://resources", + pattern="authentication", case_insensitive=True, node_limit=1024, ) @@ -950,13 +964,17 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # 查找所有 markdown 文件(默认最多返回 256 条) -results = client.glob("**/*.md", "viking://resources") +results = client.glob(pattern="**/*.md", uri="viking://resources") print(f"Found {results['count']} markdown files:") for uri in results['matches']: print(f" {uri}") # 查找所有 Python 文件,并显式放宽返回上限 -results = client.glob("**/*.py", "viking://resources", node_limit=1024) +results = client.glob( + pattern="**/*.py", + uri="viking://resources", + node_limit=1024, +) print(f"Found {results['count']} Python files") ``` @@ -1020,7 +1038,7 @@ import openviking as ov client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() -results = client.find("authentication") +results = client.find(query="authentication") for ctx in results.resources: # 从 L0(摘要)开始 - 已包含在 ctx.abstract 中 @@ -1028,11 +1046,11 @@ for ctx in results.resources: if ctx.level < 2: # 获取 L1(概览)用于目录 - overview = client.overview(ctx.uri) + overview = client.overview(uri=ctx.uri) print(f"Overview: {overview[:500]}...") else: # 加载 L2(内容)用于文件 - content = client.read(ctx.uri) + content = client.read(uri=ctx.uri) print(f"File content: {content}") ``` @@ -1064,13 +1082,13 @@ import openviking as ov client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() -results = client.find("OAuth implementation") +results = client.find(query="OAuth implementation") for ctx in results.resources: print(f"Found: {ctx.uri}") # 获取关联资源 - relations = client.relations(ctx.uri) + relations = client.relations(uri=ctx.uri) for rel in relations: print(f" Related: {rel['uri']} - {rel['reason']}") ``` @@ -1094,10 +1112,10 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # 好 - 具体的查询 -results = client.find("OAuth 2.0 authorization code flow implementation") +results = client.find(query="OAuth 2.0 authorization code flow implementation") # 效果较差 - 过于宽泛 -results = client.find("auth") +results = client.find(query="auth") ``` ### 限定搜索范围 @@ -1110,8 +1128,8 @@ client.initialize() # 在相关范围内搜索以获得更好的结果 results = client.find( - "error handling", - target_uri="viking://resources/my-project" + query="error handling", + options={"target_uri": "viking://resources/my-project"}, ) ``` @@ -1125,13 +1143,20 @@ client = ov.SyncHTTPClient(url="http://localhost:1933", api_key="your-key") client.initialize() # 对于对话式搜索,使用会话 -session = client.session() -session.add_message("user", [ - TextPart(text="I'm building a login page") -]) +session_info = client.create_session() +session = client.session(session_id=session_info["session_id"]) +session.add_message( + message={ + "role": "user", + "parts": [TextPart(text="I'm building a login page")], + } +) # 搜索能够理解上下文 -results = client.search("best practices", session=session) +results = client.search( + query="best practices", + options={"session_id": session.session_id}, +) ``` ## 相关文档 diff --git a/docs/zh/api/11-snapshot.md b/docs/zh/api/11-snapshot.md index b665586e56..fb95e4aad1 100644 --- a/docs/zh/api/11-snapshot.md +++ b/docs/zh/api/11-snapshot.md @@ -659,11 +659,19 @@ client.initialize() root = "viking://resources/my_project" # 1. 写入初始内容并提交 v1 -client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", mode="create", wait=True) +client.write( + uri=f"{root}/guide.md", + content="# Guide\n\nv1 content\n", + options={"mode": "create", "wait": True}, +) v1 = client.snapshot.commit(message="v1 initial import") # 2. 修改后再提交 v2 -client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", mode="replace", wait=True) +client.write( + uri=f"{root}/guide.md", + content="# Guide\n\nv2 content\n", + options={"mode": "replace", "wait": True}, +) v2 = client.snapshot.commit(message="v2 update") # 3. 查看历史 diff --git a/docs/zh/api/12-content.md b/docs/zh/api/12-content.md index 3fe115438f..ebca7708a7 100644 --- a/docs/zh/api/12-content.md +++ b/docs/zh/api/12-content.md @@ -18,7 +18,7 @@ **Python SDK** ```python -abstract = client.abstract("viking://resources/docs/") +abstract = client.abstract(uri="viking://resources/docs/") print(f"Abstract: {abstract}") # Output: "Documentation for the project API, covering authentication, endpoints..." ``` @@ -84,7 +84,7 @@ openviking abstract viking://resources/docs/ **Python SDK** ```python -overview = client.overview("viking://resources/docs/") +overview = client.overview(uri="viking://resources/docs/") print(f"Overview:\n{overview}") ``` @@ -157,7 +157,7 @@ openviking overview viking://resources/docs/ **Python SDK** ```python -content = client.read("viking://resources/docs/api.md") +content = client.read(uri="viking://resources/docs/api.md") print(f"Content:\n{content}") ``` @@ -235,10 +235,9 @@ openviking read viking://resources/docs/api.md ```python result = client.write( - "viking://resources/docs/api.md", - "# Updated API\n\nFresh content.", - mode="replace", - wait=True, + uri="viking://resources/docs/api.md", + content="# Updated API\n\nFresh content.", + options={"mode": "replace", "wait": True}, ) print(result["root_uri"]) ``` @@ -476,10 +475,9 @@ Content-Disposition: attachment; filename*=UTF-8''logo.png ```python result = client.set_tags( - "viking://resources/project/", - ["team=search", "env=prod"], - mode="replace", - recursive=True, + uri="viking://resources/project/", + tags=["team=search", "env=prod"], + options={"mode": "replace", "recursive": True}, ) ``` @@ -623,10 +621,12 @@ session 子树会被跳过。 ```python result = client.reindex( uri="viking://resources", - mode="vectors_only", - wait=True, - tags=["team=search", "env=prod"], - tag_mode="replace", + options={ + "mode": "vectors_only", + "wait": True, + "tags": ["team=search", "env=prod"], + "tag_mode": "replace", + }, ) print(result) ``` @@ -634,8 +634,7 @@ print(result) ```python result = client.reindex( uri="viking://user/default/skills", - mode="semantic_and_vectors", - wait=False, + options={"mode": "semantic_and_vectors", "wait": False}, ) print(result["status"]) ``` @@ -643,8 +642,7 @@ print(result["status"]) ```python result = client.reindex( uri="viking://resources", - mode="prune_orphans", - dry_run=True, + options={"mode": "prune_orphans", "dry_run": True}, ) print(result["would_delete_records"]) ``` diff --git a/docs/zh/api/99-api-doc-writing-guide.md b/docs/zh/api/99-api-doc-writing-guide.md index 49188296e2..88e88fd98a 100644 --- a/docs/zh/api/99-api-doc-writing-guide.md +++ b/docs/zh/api/99-api-doc-writing-guide.md @@ -49,7 +49,7 @@ API 文档按模块组织,每个模块一个文件,使用两位数字序号 **Python SDK** -```python +```text ``` @@ -223,8 +223,8 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://localhost:1933", api_key="your-key") result = client.add_resource( - "./documents/guide.md", - reason="User guide documentation" + path="./documents/guide.md", + options={"reason": "User guide documentation"}, ) print(f"Added: {result['root_uri']}") diff --git a/docs/zh/concepts/02-context-types.md b/docs/zh/concepts/02-context-types.md index cf92689dee..b1111e6386 100644 --- a/docs/zh/concepts/02-context-types.md +++ b/docs/zh/concepts/02-context-types.md @@ -32,13 +32,13 @@ # 添加资源 client.add_resource( "https://docs.example.com/api.pdf", - reason="API 文档" + {"reason": "API 文档"}, ) # 搜索资源 results = client.find( "认证方法", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) ``` @@ -82,7 +82,7 @@ task = await client.get_task(commit["task_id"]) # 轮询直到 task["status"] = # 搜索记忆 results = await client.find( "用户界面偏好", - target_uri="viking://user/memories/" + {"target_uri": "viking://user/memories/"}, ) ``` @@ -137,13 +137,13 @@ ov skills add search-web -p viking://agent/skills # 搜索用户技能 results = await client.find( "网络搜索", - target_uri="viking://user/skills/" + {"target_uri": "viking://user/skills/"}, ) # 搜索全局 agent 技能 results = await client.find( "网络搜索", - target_uri="viking://agent/skills/" + {"target_uri": "viking://agent/skills/"}, ) ``` diff --git a/docs/zh/concepts/04-viking-uri.md b/docs/zh/concepts/04-viking-uri.md index 715e9bb447..3fde494666 100644 --- a/docs/zh/concepts/04-viking-uri.md +++ b/docs/zh/concepts/04-viking-uri.md @@ -277,25 +277,25 @@ parent = VikingURI(uri).parent.uri # viking://resources/docs # 仅在资源中搜索 results = client.find( "认证", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) # 仅在当前用户资源中搜索 results = client.find( "私有项目笔记", - target_uri="viking://user/resources/" + {"target_uri": "viking://user/resources/"}, ) # 仅在用户记忆中搜索 results = client.find( "编码偏好", - target_uri="viking://user/memories/" + {"target_uri": "viking://user/memories/"}, ) # 仅在技能中搜索 results = client.find( "网络搜索", - target_uri="viking://user/skills/" + {"target_uri": "viking://user/skills/"}, ) ``` diff --git a/docs/zh/concepts/06-extraction.md b/docs/zh/concepts/06-extraction.md index 62df4ab5c6..43fe18b46a 100644 --- a/docs/zh/concepts/06-extraction.md +++ b/docs/zh/concepts/06-extraction.md @@ -164,7 +164,7 @@ SemanticMsg( # 添加资源 await client.add_resource( "/path/to/doc.pdf", - reason="API 文档" + {"reason": "API 文档"}, ) # 流程: Parser → TreeBuilder(scope=resources) → SemanticQueue diff --git a/docs/zh/concepts/07-retrieval.md b/docs/zh/concepts/07-retrieval.md index c85c89ff73..e3c6eb4477 100644 --- a/docs/zh/concepts/07-retrieval.md +++ b/docs/zh/concepts/07-retrieval.md @@ -26,7 +26,7 @@ OpenViking 采用两阶段检索:意图分析 + 层级检索 + Rerank。 # find(): 简单查询 results = await client.find( "OAuth 认证", - target_uri="viking://resources/" + {"target_uri": "viking://resources/"}, ) # search(): 复杂任务(需要会话上下文) diff --git a/docs/zh/faq/faq.md b/docs/zh/faq/faq.md index 6cc27d47a2..1351bc44d3 100644 --- a/docs/zh/faq/faq.md +++ b/docs/zh/faq/faq.md @@ -162,15 +162,17 @@ Embedding、VLM、存储等服务配置由 OpenViking Server 通过 `ov.conf` ```python # 添加单个文件 await client.add_resource( - "./document.pdf", - reason="项目技术文档", # 描述资源用途,提升检索质量 - to="viking://resources/docs/" # 指定存储位置 + path="./document.pdf", + options={ + "reason": "项目技术文档", # 描述资源用途,提升检索质量 + "to": "viking://resources/docs/", # 指定存储位置 + }, ) # 添加网页 await client.add_resource( - "https://example.com/api-docs", - reason="API 参考文档" + path="https://example.com/api-docs", + options={"reason": "API 参考文档"}, ) # 等待处理完成 @@ -189,14 +191,14 @@ await client.wait_processed() ```python # find(): 简单直接的语义搜索 results = await client.find( - "OAuth 认证流程", - target_uri="viking://resources/" + query="OAuth 认证流程", + options={"target_uri": "viking://resources/"}, ) # search(): 复杂任务,需要意图分析 results = await client.search( - "帮我实现用户登录功能", - session_info=session + query="帮我实现用户登录功能", + options={"session_id": session.session_id}, ) ``` @@ -213,8 +215,12 @@ results = await client.search( session = client.session() # 添加对话消息 -await session.add_message("user", [{"type": "text", "text": "帮我分析这段代码的性能问题"}]) -await session.add_message("assistant", [{"type": "text", "text": "我来分析一下..."}]) +await session.add_message( + message={"role": "user", "parts": [{"type": "text", "text": "帮我分析这段代码的性能问题"}]} +) +await session.add_message( + message={"role": "assistant", "parts": [{"type": "text", "text": "我来分析一下..."}]} +) # 标记使用的上下文(用于追踪) await session.used(["viking://resources/code/main.py"]) @@ -233,16 +239,16 @@ OpenViking 内置 `profile`、`preferences`、`entities`、`events`、`identity` ```python # 列出目录内容 -items = await client.ls("viking://resources/") +items = await client.ls(uri="viking://resources/") # 读取完整内容(L2) -content = await client.read("viking://resources/doc.md") +content = await client.read(uri="viking://resources/doc.md") # 获取摘要(L0) -abstract = await client.abstract("viking://resources") +abstract = await client.abstract(uri="viking://resources") # 获取概览(L1) -overview = await client.overview("viking://resources") +overview = await client.overview(uri="viking://resources") ``` ## 检索优化 @@ -285,7 +291,7 @@ OpenViking 使用分数传播机制: 1. **未等待处理完成** ```python - await client.add_resource("./doc.pdf") + await client.add_resource(path="./doc.pdf") await client.wait_processed() # 必须等待 ``` @@ -310,7 +316,7 @@ OpenViking 使用分数传播机制: 1. **确认资源已处理完成** ```python # 检查资源是否存在 - items = await client.ls("viking://resources/") + items = await client.ls(uri="viking://resources/") ``` 2. **检查 `target_uri` 过滤条件** @@ -323,7 +329,7 @@ OpenViking 使用分数传播机制: 4. **检查 L0 摘要质量** ```python - abstract = await client.abstract("viking://resources/your-doc") + abstract = await client.abstract(uri="viking://resources/your-doc") print(abstract) # 确认摘要是否准确反映内容 ``` @@ -346,7 +352,10 @@ OpenViking 使用分数传播机制: 4. **查看提取的记忆** ```python - memories = await client.find("", target_uri="viking://user/memories/") + memories = await client.find( + query="", + options={"target_uri": "viking://user/memories/"}, + ) ``` ### 性能问题 diff --git a/docs/zh/getting-started/02-quickstart.md b/docs/zh/getting-started/02-quickstart.md index 389a3cff3a..465dc1e8cc 100644 --- a/docs/zh/getting-started/02-quickstart.md +++ b/docs/zh/getting-started/02-quickstart.md @@ -189,27 +189,30 @@ try: print("Wait for semantic processing...") add_result = client.add_resource( path="https://raw.githubusercontent.com/volcengine/OpenViking/refs/heads/main/README.md", - wait=True, + options={"wait": True}, ) root_uri = add_result['root_uri'] # Explore the resource tree structure - ls_result = client.ls(root_uri) + ls_result = client.ls(uri=root_uri) print(f"Directory structure:\n{ls_result}\n") # Use glob to find markdown files glob_result = client.glob(pattern="**/*.md", uri=root_uri) if glob_result['matches']: - content = client.read(glob_result['matches'][0]) + content = client.read(uri=glob_result["matches"][0]) print(f"Content preview: {content[:200]}...\n") # Get abstract and overview of the resource - abstract = client.abstract(root_uri) - overview = client.overview(root_uri) + abstract = client.abstract(uri=root_uri) + overview = client.overview(uri=root_uri) print(f"Abstract:\n{abstract}\n\nOverview:\n{overview}\n") # Perform semantic search - results = client.find("what is openviking", target_uri=root_uri) + results = client.find( + query="what is openviking", + options={"target_uri": root_uri}, + ) print("Search results:") for result in results.get("resources", []): print(f" {result['uri']} (score: {result.get('score', 0.0):.4f})") diff --git a/docs/zh/getting-started/03-quickstart-server.md b/docs/zh/getting-started/03-quickstart-server.md index f324d732d8..77ba856f5e 100644 --- a/docs/zh/getting-started/03-quickstart-server.md +++ b/docs/zh/getting-started/03-quickstart-server.md @@ -117,7 +117,7 @@ try: client.wait_processed() # Search - results = client.find("what is openviking", target_uri=root_uri) + results = client.find("what is openviking", {"target_uri": root_uri}) for r in results.resources: print(f" {r.uri} (score: {r.score:.4f})") diff --git a/docs/zh/guides/02-volcengine-purchase-guide.md b/docs/zh/guides/02-volcengine-purchase-guide.md index 6ec62791bf..ae7622c886 100644 --- a/docs/zh/guides/02-volcengine-purchase-guide.md +++ b/docs/zh/guides/02-volcengine-purchase-guide.md @@ -182,8 +182,8 @@ async def test(): # 添加简单资源测试 result = await client.add_resource( - "https://example.com", - reason="测试连接" + path="https://example.com", + options={"reason": "测试连接"}, ) print(f"✓ 配置成功: {result['root_uri']}") diff --git a/docs/zh/guides/07-operation-telemetry.md b/docs/zh/guides/07-operation-telemetry.md index 1666bb0ef9..3a41d674a2 100644 --- a/docs/zh/guides/07-operation-telemetry.md +++ b/docs/zh/guides/07-operation-telemetry.md @@ -334,7 +334,10 @@ from openviking_sdk import AsyncHTTPClient client = AsyncHTTPClient(url="http://localhost:1933", api_key="your-key") await client.initialize() -result = await client.find("memory dedup", telemetry=True) +result = await client.find( + query="memory dedup", + options={"telemetry": True}, +) print(result["telemetry"]["summary"]["operation"]) print(result["telemetry"]["summary"]["duration_ms"]) ``` diff --git a/docs/zh/guides/08-encryption.md b/docs/zh/guides/08-encryption.md index 0aefe88f6a..f204cecc59 100644 --- a/docs/zh/guides/08-encryption.md +++ b/docs/zh/guides/08-encryption.md @@ -68,10 +68,13 @@ async def test(): # add_resource 接收文件路径或 URL sample = Path("./encrypted-sample.txt") sample.write_text("Hello, encrypted world!", encoding="utf-8") - await client.add_resource(str(sample), reason="测试加密") + await client.add_resource( + path=str(sample), + options={"reason": "测试加密"}, + ) # 读取资源(自动解密) - results = await client.find("encrypted") + results = await client.find(query="encrypted") print(f"找到 {len(results)} 个结果") await client.close() diff --git a/docs/zh/guides/15-snapshot.md b/docs/zh/guides/15-snapshot.md index 5efa4755e6..f31b4df3ae 100644 --- a/docs/zh/guides/15-snapshot.md +++ b/docs/zh/guides/15-snapshot.md @@ -137,12 +137,12 @@ client.initialize() root = "viking://resources/my_project" # 1. 写入初始内容并提交 v1 -client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", mode="create", wait=True) +client.write(f"{root}/guide.md", "# Guide\n\nv1 content\n", {"mode": "create", "wait": True}) v1 = client.snapshot.commit(message="v1 initial import") print("v1:", v1["commit_oid"]) # 2. 修改后再提交 v2 -client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", mode="replace", wait=True) +client.write(f"{root}/guide.md", "# Guide\n\nv2 content\n", {"mode": "replace", "wait": True}) v2 = client.snapshot.commit(message="v2 update") # 3. 查看历史 diff --git a/examples/basic-usage/README.md b/examples/basic-usage/README.md index 694ab12cde..bc636bb588 100644 --- a/examples/basic-usage/README.md +++ b/examples/basic-usage/README.md @@ -104,14 +104,14 @@ Add a URL, local file, or directory: ```python result = client.add_resource( path="https://example.com/docs", - wait=False, + options={"wait": False}, ) result = client.add_resource(path="/path/to/manual.pdf") result = client.add_resource( path="/path/to/repo", - instruction="This is a Python web application", + options={"instruction": "This is a Python web application"}, ) ``` @@ -123,9 +123,9 @@ asynchronously and call `wait_processed()` when you actually need the indexed re OpenViking organizes context as a virtual filesystem: ```python -files = client.ls("viking://resources/") -tree = client.tree("viking://resources/my-project", level_limit=3) -content = client.read("viking://resources/my-project/README.md") +files = client.ls(uri="viking://resources/") +tree = client.tree(uri="viking://resources/my-project", level_limit=3) +content = client.read(uri="viking://resources/my-project/README.md") ``` This same URI model applies to memories and skills as well: @@ -141,14 +141,12 @@ Use `find` for fast semantic search and `search` for more advanced retrieval: ```python results = client.find( query="how does authentication work", - target_uri="viking://resources/my-project", - limit=5, + options={"target_uri": "viking://resources/my-project", "limit": 5}, ) results = client.search( query="database configuration and failure handling", - target_uri="viking://resources/", - limit=10, + options={"target_uri": "viking://resources/", "limit": 10}, ) ``` @@ -157,15 +155,19 @@ Use tiered loading after retrieval: ```python uri = "viking://resources/my-project/docs/api.md" -abstract = client.abstract(uri) -overview = client.overview(uri) -content = client.read(uri) +abstract = client.abstract(uri=uri) +overview = client.overview(uri=uri) +content = client.read(uri=uri) ``` Use `grep` when you need literal text matching instead of semantic retrieval: ```python -result = client.grep("viking://resources/my-project", "Agent", case_insensitive=True) +result = client.grep( + uri="viking://resources/my-project", + pattern="Agent", + case_insensitive=True, +) matches = result.get("matches", []) ``` @@ -177,14 +179,20 @@ The example script creates a session and appends messages: session_info = client.create_session() session_id = session_info["session_id"] -client.add_message(session_id, "user", "I prefer TypeScript over JavaScript") -client.add_message(session_id, "assistant", "Understood. I will use TypeScript where appropriate.") +client.add_message( + session_id=session_id, + message={"role": "user", "content": "I prefer TypeScript over JavaScript"}, +) +client.add_message( + session_id=session_id, + message={"role": "assistant", "content": "Understood. I will use TypeScript where appropriate."}, +) ``` To extract durable memories from that conversation, commit the session: ```python -client.commit_session(session_id) +client.commit_session(session_id=session_id) ``` After commit, you can retrieve those memories through normal search APIs: @@ -192,7 +200,7 @@ After commit, you can retrieve those memories through normal search APIs: ```python memories = client.find( query="user programming preferences", - target_uri="viking://user/memories/", + options={"target_uri": "viking://user/memories/"}, ) ``` diff --git a/examples/basic-usage/README_CN.md b/examples/basic-usage/README_CN.md index 1492173967..5b39b04b1b 100644 --- a/examples/basic-usage/README_CN.md +++ b/examples/basic-usage/README_CN.md @@ -105,14 +105,14 @@ client = SyncHTTPClient( ```python result = client.add_resource( path="https://example.com/docs", - wait=False, + options={"wait": False}, ) result = client.add_resource(path="/path/to/manual.pdf") result = client.add_resource( path="/path/to/repo", - instruction="这是一个 Python Web 应用", + options={"instruction": "这是一个 Python Web 应用"}, ) ``` @@ -124,9 +124,9 @@ result = client.add_resource( OpenViking 的上下文统一组织在虚拟文件系统里: ```python -files = client.ls("viking://resources/") -tree = client.tree("viking://resources/my-project", level_limit=3) -content = client.read("viking://resources/my-project/README.md") +files = client.ls(uri="viking://resources/") +tree = client.tree(uri="viking://resources/my-project", level_limit=3) +content = client.read(uri="viking://resources/my-project/README.md") ``` 同样的 URI 模型也适用于记忆和技能: @@ -142,14 +142,12 @@ content = client.read("viking://resources/my-project/README.md") ```python results = client.find( query="认证逻辑是怎么做的", - target_uri="viking://resources/my-project", - limit=5, + options={"target_uri": "viking://resources/my-project", "limit": 5}, ) results = client.search( query="数据库配置和故障处理", - target_uri="viking://resources/", - limit=10, + options={"target_uri": "viking://resources/", "limit": 10}, ) ``` @@ -158,15 +156,19 @@ results = client.search( ```python uri = "viking://resources/my-project/docs/api.md" -abstract = client.abstract(uri) -overview = client.overview(uri) -content = client.read(uri) +abstract = client.abstract(uri=uri) +overview = client.overview(uri=uri) +content = client.read(uri=uri) ``` 如果你要的是字面匹配而不是语义检索,用 `grep`: ```python -result = client.grep("viking://resources/my-project", "Agent", case_insensitive=True) +result = client.grep( + uri="viking://resources/my-project", + pattern="Agent", + case_insensitive=True, +) matches = result.get("matches", []) ``` @@ -178,14 +180,20 @@ matches = result.get("matches", []) session_info = client.create_session() session_id = session_info["session_id"] -client.add_message(session_id, "user", "我更喜欢 TypeScript 而不是 JavaScript") -client.add_message(session_id, "assistant", "明白了,在合适场景下我会优先使用 TypeScript。") +client.add_message( + session_id=session_id, + message={"role": "user", "content": "我更喜欢 TypeScript 而不是 JavaScript"}, +) +client.add_message( + session_id=session_id, + message={"role": "assistant", "content": "明白了,在合适场景下我会优先使用 TypeScript。"}, +) ``` 如果要把这段对话真正提取成长期记忆,需要提交 session: ```python -client.commit_session(session_id) +client.commit_session(session_id=session_id) ``` 提交后,记忆可以通过正常检索接口再次找回: @@ -193,7 +201,7 @@ client.commit_session(session_id) ```python memories = client.find( query="用户编程偏好", - target_uri="viking://user/memories/", + options={"target_uri": "viking://user/memories/"}, ) ``` diff --git a/examples/basic-usage/basic_usage.py b/examples/basic-usage/basic_usage.py index 6bba82c4a6..c1cc53ec12 100644 --- a/examples/basic-usage/basic_usage.py +++ b/examples/basic-usage/basic_usage.py @@ -70,14 +70,14 @@ def main(): # Add a URL resource result = client.add_resource( path="https://raw.githubusercontent.com/volcengine/OpenViking/refs/heads/main/README.md", - wait=False, # Non-blocking, process in background + options={"wait": False}, # Non-blocking, process in background ) root_uri = result.get("root_uri", "") print(f" Root URI: {root_uri}") # Get the file count - files = client.ls(root_uri) + files = client.ls(uri=root_uri) print(f" Files indexed: {len(files)}") except Exception as e: @@ -96,13 +96,13 @@ def main(): try: # List directory contents print(" Directory listing:") - files = client.ls(root_uri, simple=True) + files = client.ls(uri=root_uri, simple=True) for f in files[:5]: # Show first 5 files print(f" - {f}") # Show tree structure print("\n Tree view:") - tree = client.tree(root_uri, level_limit=2) + tree = client.tree(uri=root_uri, level_limit=2) print_tree(tree, indent=" ") except Exception as e: @@ -136,7 +136,7 @@ def main(): try: # L0: Abstract (quick summary ~100 tokens) print(" L0 (Abstract):") - abstract = client.abstract(root_uri) + abstract = client.abstract(uri=root_uri) if abstract: # Show first 200 characters preview = abstract[:200] + "..." if len(abstract) > 200 else abstract @@ -148,7 +148,7 @@ def main(): # L1: Overview (key points ~2k tokens) print(" L1 (Overview):") - overview = client.overview(root_uri) + overview = client.overview(uri=root_uri) if overview: preview = overview[:300] + "..." if len(overview) > 300 else overview print(f" {preview}") @@ -162,7 +162,7 @@ def main(): glob_result = client.glob(pattern="**/*.md", uri=root_uri) matches = glob_result.get("matches", []) if isinstance(glob_result, dict) else [] if matches: - content = client.read(matches[0]) + content = client.read(uri=matches[0]) preview = content[:500] + "..." if len(content) > 500 else content print(f" File: {matches[0]}") print(f" {preview}") @@ -186,7 +186,10 @@ def main(): print(f" Query: '{query}'") print(" Results:") - results = client.find(query=query, target_uri=root_uri, limit=5) + results = client.find( + query=query, + options={"target_uri": root_uri, "limit": 5}, + ) resources = results.get("resources", []) if resources: @@ -212,7 +215,7 @@ def main(): pattern = "Agent" print(f" Pattern: '{pattern}'") - result = client.grep(root_uri, pattern, case_insensitive=True) + result = client.grep(uri=root_uri, pattern=pattern, case_insensitive=True) matches = result.get("matches", []) print(f" Found {len(matches)} matches") @@ -239,9 +242,16 @@ def main(): print(f" Created session: {session_id}") # Add a conversation turn - client.add_message(session_id, "user", "I prefer Python for data science projects") client.add_message( - session_id, "assistant", "Understood! I'll use Python for your data science work." + session_id=session_id, + message={"role": "user", "content": "I prefer Python for data science projects"}, + ) + client.add_message( + session_id=session_id, + message={ + "role": "assistant", + "content": "Understood! I'll use Python for your data science work.", + }, ) print(" Added conversation turn") diff --git a/examples/common/recipe.py b/examples/common/recipe.py index 4f6d9f5211..1870ab83ad 100644 --- a/examples/common/recipe.py +++ b/examples/common/recipe.py @@ -71,7 +71,13 @@ def search( # Search all resources or specific target # `find` has better performance, but not so smart - results = self.client.search(query, target_uri=target_uri, score_threshold=score_threshold) + results = self.client.search( + query=query, + options={ + "target_uri": target_uri, + "score_threshold": score_threshold, + }, + ) # Extract top results search_results = [] diff --git a/examples/langchain-langgraph/langgraph/agent/live_app.py b/examples/langchain-langgraph/langgraph/agent/live_app.py index b9b17ad99b..1c5823e33a 100644 --- a/examples/langchain-langgraph/langgraph/agent/live_app.py +++ b/examples/langchain-langgraph/langgraph/agent/live_app.py @@ -16,14 +16,13 @@ from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_openviking import OpenVikingContextMiddleware +from langchain_openviking.client import extract_message_text from langgraph.graph import END, START, StateGraph from langgraph.graph.message import add_messages from openai import OpenAI from typing_extensions import Annotated, TypedDict -from langchain_openviking import OpenVikingContextMiddleware -from langchain_openviking.client import extract_message_text - class LiveState(TypedDict, total=False): messages: Annotated[list, add_messages] @@ -42,29 +41,33 @@ def build_context_client(): def seed_context(client, session_id: str, code: str) -> None: - client.create_session(session_id=session_id) + client.create_session(options={"session_id": session_id}) client.add_message( session_id=session_id, - role="user", - parts=[ - { - "type": "text", - "text": ( - f"Remember this OpenViking LangGraph live e2e exact code: {code}. " - "This is durable session context for the next agent turn." - ), - } - ], + message={ + "role": "user", + "parts": [ + { + "type": "text", + "text": ( + f"Remember this OpenViking LangGraph live e2e exact code: {code}. " + "This is durable session context for the next agent turn." + ), + } + ], + }, ) client.add_message( session_id=session_id, - role="assistant", - parts=[ - { - "type": "text", - "text": f"Stored the OpenViking LangGraph live e2e exact code: {code}.", - } - ], + message={ + "role": "assistant", + "parts": [ + { + "type": "text", + "text": f"Stored the OpenViking LangGraph live e2e exact code: {code}.", + } + ], + }, ) @@ -178,12 +181,12 @@ def main() -> str: print(answer) if code not in answer.lower(): raise RuntimeError(f"Expected {code!r} in live answer: {answer!r}") - commit = client.commit_session(session_id) + commit = client.commit_session(session_id=session_id) wait_for_commit_task(client, commit) return answer finally: try: - client.delete_session(session_id) + client.delete_session(session_id=session_id) except Exception: pass @@ -195,7 +198,7 @@ def wait_for_commit_task(client, commit: dict[str, object]) -> None: deadline = time.monotonic() + timeout last_task = None while time.monotonic() < deadline: - task = client.get_task(str(commit["task_id"])) + task = client.get_task(task_id=str(commit["task_id"])) last_task = task if task and task.get("status") == "completed": return diff --git a/examples/quick_start.py b/examples/quick_start.py index 325cac675a..9ffa517687 100644 --- a/examples/quick_start.py +++ b/examples/quick_start.py @@ -21,22 +21,25 @@ print("Wait for semantic processing...") res = client.add_resource( path="https://raw.githubusercontent.com/volcengine/OpenViking/refs/heads/main/README.md", - wait=True, + options={"wait": True}, ) root_uri = res["root_uri"] - res = client.ls(root_uri) # Explore resource tree + res = client.ls(uri=root_uri) # Explore resource tree print(f"Directory structure:\n{res}\n") res = client.glob(pattern="**/*.md", uri=root_uri) # use glob to find markdown files if res["matches"]: - content = client.read(res["matches"][0]) + content = client.read(uri=res["matches"][0]) print(f"Content preview: {content[:200]}...\n") - abstract = client.abstract(root_uri) # Get abstract - overview = client.overview(root_uri) # Get overview + abstract = client.abstract(uri=root_uri) # Get abstract + overview = client.overview(uri=root_uri) # Get overview print(f"Abstract:\n{abstract}\n\nOverview:\n{overview}\n") - results = client.find("what is openviking", target_uri=root_uri) # Semantic search + results = client.find( + query="what is openviking", + options={"target_uri": root_uri}, + ) # Semantic search print("Search results:") for result in results.get("resources", []): print(f" {result['uri']} (score: {result.get('score', 0.0):.4f})") diff --git a/examples/snapshot/snapshot_example.py b/examples/snapshot/snapshot_example.py index ac20aedcb3..de98ce99ff 100644 --- a/examples/snapshot/snapshot_example.py +++ b/examples/snapshot/snapshot_example.py @@ -32,17 +32,24 @@ def short_oid(commit_oid: str | None) -> str: def write_text(client: Any, uri: str, content: str, mode: str) -> None: - result = client.write(uri, content, mode=mode, wait=True, timeout=WAIT_TIMEOUT) + result = client.write( + uri=uri, + content=content, + options={"mode": mode, "wait": True, "timeout": WAIT_TIMEOUT}, + ) print(f"write: {uri} (mode={result.get('mode')}, bytes={result.get('written_bytes')})") def remove_resource(client: Any, uri: str) -> None: - client.rm(uri, wait=True, timeout=WAIT_TIMEOUT) + client.rm(uri=uri, wait=True, timeout=WAIT_TIMEOUT) print(f"rm: {uri}") def print_find(client: Any, query: str, root_uri: str) -> None: - results = client.find(query, target_uri=root_uri, limit=10) + results = client.find( + query=query, + options={"target_uri": root_uri, "limit": 10}, + ) resources = results.get("resources", []) if not resources: print(f"find {query!r}: (no matches)") @@ -53,7 +60,7 @@ def print_find(client: Any, query: str, root_uri: str) -> None: def print_read(client: Any, uri: str) -> None: - content = client.read(uri) + content = client.read(uri=uri) first_line = content.splitlines()[0] if content else "" print(f"read {uri}: {len(content)} chars | {first_line}") @@ -83,7 +90,7 @@ def wait_for_task( return deadline = time.time() + timeout while True: - task = client.get_task(task_id) or {} + task = client.get_task(task_id=task_id) or {} status = task.get("status") if status in ("completed", "failed"): print(f"wait_for_task {task_id[:12]}: {status}") @@ -113,8 +120,8 @@ def main() -> None: print_section("setup") print(f"server: {OPENVIKING_URL}") print(f"workspace: {root_uri}") - client.mkdir(root_uri) - client.mkdir(f"{root_uri}/notes") + client.mkdir(uri=root_uri) + client.mkdir(uri=f"{root_uri}/notes") print(f"mkdir: {root_uri}, {root_uri}/notes") print_section("v1 initial import") @@ -139,7 +146,7 @@ def main() -> None: print_find(client, changelog, root_uri) print_section("v3 second changes") - client.mkdir(f"{root_uri}/archive") + client.mkdir(uri=f"{root_uri}/archive") print(f"mkdir: {root_uri}/archive") write_text( client, @@ -174,7 +181,7 @@ def main() -> None: f"written={len(restore.get('written_paths') or [])} deleted={len(restore.get('deleted_paths') or [])}" ) wait_for_task(client, restore.get("task_id")) - entries = client.ls(root_uri, recursive=True) + entries = client.ls(uri=root_uri, recursive=True) print(f"ls after restore: {len(entries)} entry(ies)") for entry in entries: print(f" {entry.get('uri') if isinstance(entry, dict) else entry}") diff --git a/examples/watch_resource_example.py b/examples/watch_resource_example.py index 88f945e7d8..d4d4684024 100644 --- a/examples/watch_resource_example.py +++ b/examples/watch_resource_example.py @@ -43,10 +43,12 @@ async def example_basic_watch(): print("\nAdding resource with watch_interval=60.0 minutes...") result = await client.add_resource( path=str(test_file), - to=to_uri, - reason="Example: monitoring a document", - instruction="Check for updates and re-index", - watch_interval=60.0, + options={ + "to": to_uri, + "reason": "Example: monitoring a document", + "instruction": "Check for updates and re-index", + "watch_interval": 60.0, + }, ) print("Resource added successfully!") @@ -66,14 +68,15 @@ async def example_update_watch_interval(): print("\nUpdating watch interval by canceling then re-creating...") await client.add_resource( path=str(test_file), - to=to_uri, - watch_interval=0, + options={"to": to_uri, "watch_interval": 0}, ) await client.add_resource( path=str(test_file), - to=to_uri, - reason="Updated: more frequent monitoring", - watch_interval=120.0, + options={ + "to": to_uri, + "reason": "Updated: more frequent monitoring", + "watch_interval": 120.0, + }, ) print("Watch task updated successfully!") finally: @@ -91,8 +94,7 @@ async def example_cancel_watch(): print("\nCancelling watch by setting interval to 0...") await client.add_resource( path=str(test_file), - to=to_uri, - watch_interval=0, + options={"to": to_uri, "watch_interval": 0}, ) print("Watch task cancelled successfully!") finally: @@ -110,8 +112,7 @@ async def example_handle_conflict(): print("\nCreating first watch task...") await client.add_resource( path=str(test_file), - to=to_uri, - watch_interval=30.0, + options={"to": to_uri, "watch_interval": 30.0}, ) print(" First watch task created successfully") @@ -119,8 +120,7 @@ async def example_handle_conflict(): try: await client.add_resource( path=str(test_file), - to=to_uri, - watch_interval=60.0, + options={"to": to_uri, "watch_interval": 60.0}, ) print(" ERROR: This should not happen!") except ConflictError as e: diff --git a/integrations/langchain/src/langchain_openviking/client.py b/integrations/langchain/src/langchain_openviking/client.py index 6ee714f6c5..af9067eec8 100644 --- a/integrations/langchain/src/langchain_openviking/client.py +++ b/integrations/langchain/src/langchain_openviking/client.py @@ -715,11 +715,14 @@ def _filter_client_kwargs(method: Any, kwargs: dict[str, Any]) -> dict[str, Any] ) if accepts_kwargs: return {key: value for key, value in kwargs.items() if value is not None} - return { - key: value - for key, value in kwargs.items() - if value is not None and key in signature.parameters - } + values = {key: value for key, value in kwargs.items() if value is not None} + filtered = {key: value for key, value in values.items() if key in signature.parameters} + extra = {key: value for key, value in values.items() if key not in signature.parameters} + if "message" in signature.parameters and "message" not in filtered and extra: + filtered["message"] = extra + elif "options" in signature.parameters and "options" not in filtered and extra: + filtered["options"] = extra + return filtered def _should_retry_method(method_name: str) -> bool: diff --git a/openviking/eval/ragas/pipeline.py b/openviking/eval/ragas/pipeline.py index 84bf8aff2a..c09d4bad1d 100644 --- a/openviking/eval/ragas/pipeline.py +++ b/openviking/eval/ragas/pipeline.py @@ -86,9 +86,8 @@ def add_documents( logger.info(f"Adding document: {path}") result = client.add_resource( - path=str(path), - wait=wait, - timeout=timeout, + str(path), + {"wait": wait, "timeout": timeout}, ) if result and "root_uri" in result: @@ -141,8 +140,8 @@ def query( # Retrieve contexts logger.debug(f"Searching for: {question}") search_result = client.search( - query=question, - limit=top_k, + question, + {"limit": top_k}, ) contexts = [] diff --git a/openviking/eval/ragas/rag_eval.py b/openviking/eval/ragas/rag_eval.py index 3d1466f3f3..396c4e8e97 100644 --- a/openviking/eval/ragas/rag_eval.py +++ b/openviking/eval/ragas/rag_eval.py @@ -117,9 +117,8 @@ async def initialize(self): logger.info(f"Adding document: {path}") try: result = client.add_resource( - path=str(path), - wait=True, - timeout=300, + str(path), + {"wait": True, "timeout": 300}, ) if result and "root_uri" in result: logger.info(f"Added: {result['root_uri']}") @@ -135,9 +134,8 @@ async def initialize(self): logger.info(f"Adding code: {path}") try: result = client.add_resource( - path=str(path), - wait=True, - timeout=300, + str(path), + {"wait": True, "timeout": 300}, ) if result and "root_uri" in result: logger.info(f"Added: {result['root_uri']}") @@ -161,7 +159,7 @@ async def retrieve(self, query: str, top_k: int = 5) -> Dict[str, Any]: start_time = time.time() try: - result = client.search(query, limit=top_k) + result = client.search(query, {"limit": top_k}) contexts = [] if result: diff --git a/sdk/go/README.md b/sdk/go/README.md index 9d51f642c0..c1d2c0ce16 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -118,11 +118,12 @@ _, err = client.UpdateSessionConfig(ctx, "demo-session", &openviking.UpdateSessi _, err = client.UpdateSessionConfig(ctx, "demo-session", &openviking.UpdateSessionConfigOptions{ AutoCommitPolicy: openviking.Map(nil), // explicit JSON null disables auto-commit }) -_, err = client.AddMessage(ctx, "demo-session", "user", openviking.AddMessageOptions{ +_, err = client.AddMessage(ctx, "demo-session", openviking.Message{ + Role: "user", Content: openviking.String("remember this deployment decision"), }) commit, err := client.CommitSession(ctx, "demo-session", &openviking.CommitSessionOptions{ - KeepRecentCount: 2, + KeepRecentCount: openviking.Int(2), EventTags: []string{"team=search", "channel=web"}, }) diff --git a/sdk/go/admin.go b/sdk/go/admin.go index 10186e30a1..cf66be805a 100644 --- a/sdk/go/admin.go +++ b/sdk/go/admin.go @@ -116,3 +116,38 @@ func (c *Client) AdminMigrate(ctx context.Context, opts *AdminMigrateOptions) (m err := c.doJSON(ctx, http.MethodPost, "/api/v1/admin/migrate", nil, map[string]any{"action": action}, &result) return result, err } + +// AdminGetAgentEvolution returns the effective Agent Evolution switch for the +// caller's account. +func (c *Client) AdminGetAgentEvolution(ctx context.Context) (map[string]any, error) { + var result map[string]any + err := c.doJSON(ctx, http.MethodGet, "/api/v1/admin/agent-evolution", nil, nil, &result) + return result, err +} + +// AdminSetAgentEvolution persists and hot-reloads Agent Evolution for the +// caller's account. +func (c *Client) AdminSetAgentEvolution(ctx context.Context, enabled bool) (map[string]any, error) { + var result map[string]any + err := c.doJSON(ctx, http.MethodPut, "/api/v1/admin/agent-evolution", nil, map[string]any{"enabled": enabled}, &result) + return result, err +} + +// AdminGetAccountSettings returns the effective and explicitly overridden +// settings for one account. +func (c *Client) AdminGetAccountSettings(ctx context.Context, accountID string) (map[string]any, error) { + var result map[string]any + err := c.doJSON(ctx, http.MethodGet, "/api/v1/admin/accounts/"+url.PathEscape(accountID)+"/settings", nil, nil, &result) + return result, err +} + +// AdminSetAccountAgentEvolution updates the allowlisted, hot-reloadable Agent +// Evolution setting for one account via PATCH. +func (c *Client) AdminSetAccountAgentEvolution(ctx context.Context, accountID string, enabled bool) (map[string]any, error) { + payload := map[string]any{ + "agent_evolution": map[string]any{"enabled": enabled}, + } + var result map[string]any + err := c.doJSON(ctx, http.MethodPatch, "/api/v1/admin/accounts/"+url.PathEscape(accountID)+"/settings", nil, payload, &result) + return result, err +} diff --git a/sdk/go/agent_evolution.go b/sdk/go/agent_evolution.go new file mode 100644 index 0000000000..67322e0ef8 --- /dev/null +++ b/sdk/go/agent_evolution.go @@ -0,0 +1,63 @@ +package openviking + +import ( + "context" + "net/http" + "net/url" +) + +// ListExperienceTrajectories lists trajectories that consumed an Experience. +func (c *Client) ListExperienceTrajectories( + ctx context.Context, + experienceURI string, + opts *ExperienceTrajectoryOptions, +) (map[string]any, error) { + query := url.Values{ + "experience_uri": []string{NormalizeURI(experienceURI)}, + } + if opts != nil { + if opts.Limit != nil { + queryInt(query, "limit", *opts.Limit) + } + if opts.Offset != nil { + queryInt(query, "offset", *opts.Offset) + } + setQueryString(query, "start_date", opts.StartDate) + setQueryString(query, "end_date", opts.EndDate) + } + var result map[string]any + err := c.doJSON( + ctx, + http.MethodGet, + "/api/v1/agent-evolution/experiences/trajectories", + query, + nil, + &result, + ) + return result, err +} + +// GetExperienceOutcomes returns the outcome distribution for an Experience. +func (c *Client) GetExperienceOutcomes( + ctx context.Context, + experienceURI string, + opts *ExperienceOutcomeOptions, +) (map[string]any, error) { + query := url.Values{ + "experience_uri": []string{NormalizeURI(experienceURI)}, + } + if opts != nil { + setQueryString(query, "start_date", opts.StartDate) + setQueryString(query, "end_date", opts.EndDate) + } + var result map[string]any + err := c.doJSON( + ctx, + http.MethodGet, + "/api/v1/agent-evolution/experiences/outcomes", + query, + nil, + &result, + ) + return result, err +} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index 9ba69c8ce9..a53c4ff01e 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -137,7 +137,7 @@ func TestFindSendsHeadersQueryAndBody(t *testing.T) { requireBodyKeysAbsent(t, body, "agent_id", "agent_uri") writeOK(t, w, map[string]any{ "resources": []map[string]any{ - {"uri": "viking://resources/docs/api.md", "context_type": "resource", "score": 0.9}, + {"uri": "viking://resources/docs/api.md", "context_type": "resource", "score": 0.9, "tags": []string{"topic=docs", "kind=api"}}, }, }) })) @@ -145,7 +145,7 @@ func TestFindSendsHeadersQueryAndBody(t *testing.T) { result, err := client.Find(context.Background(), "auth", &FindOptions{ TargetURI: "resources/docs", - Limit: 5, + Limit: Int(5), ContextType: []string{"resource"}, Since: "2026-06-01", Until: "2026-06-18", @@ -159,6 +159,9 @@ func TestFindSendsHeadersQueryAndBody(t *testing.T) { if len(result.Resources) != 1 || result.Resources[0].URI != "viking://resources/docs/api.md" { t.Fatalf("unexpected result: %#v", result) } + if got := result.Resources[0].Tags; len(got) != 2 || got[0] != "topic=docs" || got[1] != "kind=api" { + t.Fatalf("result tags = %#v", got) + } } func TestFindOmitsSearchFiltersWhenUnset(t *testing.T) { @@ -177,6 +180,31 @@ func TestFindOmitsSearchFiltersWhenUnset(t *testing.T) { } } +func TestFindPreservesExplicitZeroAndEmptyValues(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if got, ok := body["limit"]; !ok || got != float64(0) { + t.Fatalf("limit = %#v, present = %v", got, ok) + } + if tags, ok := body["tags"].([]any); !ok || len(tags) != 0 { + t.Fatalf("tags = %#v", body["tags"]) + } + if levels, ok := body["level"].([]any); !ok || len(levels) != 0 { + t.Fatalf("level = %#v", body["level"]) + } + writeOK(t, w, map[string]any{"resources": []any{}}) + })) + defer closeServer() + + if _, err := client.Find(context.Background(), "auth", &FindOptions{ + Limit: Int(0), + Tags: []string{}, + Level: []int{}, + }); err != nil { + t.Fatal(err) + } +} + func TestListSendsOrderingOptions(t *testing.T) { client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/fs/ls" { @@ -290,6 +318,28 @@ func TestReindexSendsExplicitEmptyTags(t *testing.T) { } } +func TestReindexSendsExtraAndRejectsOverrides(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if got := body["future_flag"]; got != false { + t.Fatalf("future_flag = %#v", got) + } + writeOK(t, w, map[string]any{"status": "completed"}) + })) + defer closeServer() + + if _, err := client.Reindex(context.Background(), "resources/demo", &ReindexOptions{ + Extra: map[string]any{"future_flag": false}, + }); err != nil { + t.Fatal(err) + } + if _, err := client.Reindex(context.Background(), "resources/demo", &ReindexOptions{ + Extra: map[string]any{"tags": []string{"team=search"}}, + }); err == nil { + t.Fatal("expected formal tags field in extra to fail") + } +} + func TestAdminCreatePathsAcceptInitialUserConfig(t *testing.T) { var seen []map[string]any client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -476,6 +526,265 @@ func TestSearchOmitsSearchFiltersWhenUnset(t *testing.T) { } } +func TestSearchContextSendsContextOptionsAndRejectsModeOverride(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method+" "+r.URL.Path != "POST /api/v1/search/search" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + body := readJSONBody(t, r) + if body["mode"] != "context" || body["query"] != "continue refactor" { + t.Fatalf("body = %#v", body) + } + if body["session_id"] != "session-1" || body["purpose"] != "coding" { + t.Fatalf("context fields = %#v", body) + } + if body["max_tokens"] != float64(3000) || body["dedup_turns"] != float64(5) { + t.Fatalf("budget fields = %#v", body) + } + writeOK(t, w, map[string]any{ + "rendered": "", + "entries": []any{}, + "stats": map[string]any{"returned": 0}, + }) + })) + defer closeServer() + + result, err := client.SearchContext(context.Background(), "continue refactor", &SearchContextOptions{ + SessionID: "session-1", + Purpose: "coding", + MaxTokens: Int(3000), + DedupTurns: Int(5), + }) + if err != nil { + t.Fatal(err) + } + if result.Rendered != "" { + t.Fatalf("result = %#v", result) + } + + if _, err := client.SearchContext(context.Background(), "query", &SearchContextOptions{ + Extra: map[string]any{"mode": "list"}, + }); err == nil || !strings.Contains(err.Error(), "mode") { + t.Fatalf("expected mode conflict, got %v", err) + } +} + +func TestWriteSendsProcessingModeAndExtra(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if body["processing_mode"] != "vectors_only" || body["future_flag"] != float64(0) { + t.Fatalf("body = %#v", body) + } + writeOK(t, w, map[string]any{"uri": "viking://resources/a.md"}) + })) + defer closeServer() + + if _, err := client.Write(context.Background(), "resources/a.md", "", &WriteOptions{ + ProcessingMode: "vectors_only", + Extra: map[string]any{"future_flag": 0}, + }); err != nil { + t.Fatal(err) + } +} + +func TestBatchWriteAndDownloadBytes(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /api/v1/content/batch-write": + body := readJSONBody(t, r) + if body["root_uri"] != "viking://resources/project" || body["future_flag"] != float64(0) { + t.Fatalf("body = %#v", body) + } + writeOK(t, w, map[string]any{"updated": 1}) + case "GET /api/v1/content/download": + if r.URL.Query().Get("uri") != "viking://resources/project/a.txt" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte{1, 2, 3}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer closeServer() + + if _, err := client.BatchWrite(context.Background(), "resources/project", []BatchWriteOperation{ + { + URI: "resources/project/a.txt", + Content: String("hello"), + Precondition: BatchWritePrecondition{ + Kind: "create_if_absent", + }, + }, + }, &BatchWriteOptions{Extra: map[string]any{"future_flag": 0}}); err != nil { + t.Fatal(err) + } + data, err := client.DownloadBytes(context.Background(), "resources/project/a.txt") + if err != nil { + t.Fatal(err) + } + if string(data) != string([]byte{1, 2, 3}) { + t.Fatalf("data = %v", data) + } +} + +func TestAddResourceExtra(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if body["future_flag"] != false { + t.Fatalf("body = %#v", body) + } + writeOK(t, w, map[string]any{"root_uri": "viking://resources/a"}) + })) + defer closeServer() + + if _, err := client.AddResource(context.Background(), "https://example.com/a.md", &AddResourceOptions{ + CreateParent: Bool(false), + Extra: map[string]any{"future_flag": false}, + }); err != nil { + t.Fatal(err) + } +} + +func TestSessionSendsLatestMessageAndRetentionFields(t *testing.T) { + var bodies []map[string]any + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bodies = append(bodies, readJSONBody(t, r)) + writeOK(t, w, map[string]any{"status": "ok"}) + })) + defer closeServer() + + if _, err := client.AddMessage(context.Background(), "session-1", Message{ + Role: "assistant", + Content: String("done"), + TurnID: "turn-1", + MessageKind: "assistant_step", + SourceMessageIDs: []string{"user-1"}, + }); err != nil { + t.Fatal(err) + } + if _, err := client.CommitSession(context.Background(), "session-1", &CommitSessionOptions{ + RetentionMode: "turn_budget", + KeepRecentTurnCount: Int(3), + RetainedMessageTokenBudget: Int(12000), + MinRawTailSteps: Int(1), + }); err != nil { + t.Fatal(err) + } + + if bodies[0]["turn_id"] != "turn-1" || bodies[0]["message_kind"] != "assistant_step" { + t.Fatalf("message = %#v", bodies[0]) + } + if bodies[1]["retention_mode"] != "turn_budget" || + bodies[1]["keep_recent_turn_count"] != float64(3) { + t.Fatalf("commit = %#v", bodies[1]) + } +} + +func TestCreateSessionSendsExtra(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if body["future_flag"] != false { + t.Fatalf("body = %#v", body) + } + writeOK(t, w, map[string]any{"session_id": "session-1"}) + })) + defer closeServer() + + if _, err := client.CreateSession(context.Background(), &CreateSessionOptions{ + Extra: map[string]any{"future_flag": false}, + }); err != nil { + t.Fatal(err) + } +} + +func TestAgentEvolutionQueries(t *testing.T) { + var paths []string + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.RequestURI()) + writeOK(t, w, map[string]any{"experience_uri": "viking://user/memories/experiences/a.md"}) + })) + defer closeServer() + + if _, err := client.ListExperienceTrajectories( + context.Background(), + "user/memories/experiences/a.md", + &ExperienceTrajectoryOptions{ + Limit: Int(25), + Offset: Int(50), + StartDate: "2026-08-01", + EndDate: "2026-08-10", + }, + ); err != nil { + t.Fatal(err) + } + if _, err := client.GetExperienceOutcomes( + context.Background(), + "user/memories/experiences/a.md", + &ExperienceOutcomeOptions{ + StartDate: "2026-08-01", + EndDate: "2026-08-10", + }, + ); err != nil { + t.Fatal(err) + } + if !strings.Contains(paths[0], "limit=25") || !strings.Contains(paths[0], "offset=50") { + t.Fatalf("trajectory path = %s", paths[0]) + } + if !strings.Contains(paths[0], "start_date=2026-08-01") || + !strings.Contains(paths[0], "end_date=2026-08-10") { + t.Fatalf("trajectory dates = %s", paths[0]) + } + if !strings.Contains(paths[1], "experiences%2Fa.md") { + t.Fatalf("outcomes path = %s", paths[1]) + } + if !strings.Contains(paths[1], "start_date=2026-08-01") || + !strings.Contains(paths[1], "end_date=2026-08-10") { + t.Fatalf("outcome dates = %s", paths[1]) + } +} + +func TestOpenVikingAssetsResolveAndPreflight(t *testing.T) { + var bodies []map[string]any + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bodies = append(bodies, readJSONBody(t, r)) + writeOK(t, w, map[string]any{"ok": true}) + })) + defer closeServer() + + if _, err := client.ResolveOpenVikingAssets( + context.Background(), + "protocol: openviking-assets/1", + &ResolveAssetsOptions{ + ManifestLabel: "custom.yaml", + Extra: map[string]any{"future_flag": false}, + }, + ); err != nil { + t.Fatal(err) + } + if _, err := client.PreflightOpenVikingAsset( + context.Background(), + "private-repo", + "https://github.com/example/private.git", + &PreflightAssetOptions{ + Branch: "main", + Commit: "0123456789abcdef", + AuthConfig: &AssetGitAuth{ + Username: "oauth2", + Token: "secret", + }, + }, + ); err != nil { + t.Fatal(err) + } + + if bodies[0]["manifest_label"] != "custom.yaml" || bodies[0]["future_flag"] != false { + t.Fatalf("resolve body = %#v", bodies[0]) + } + if bodies[1]["commit"] != "0123456789abcdef" { + t.Fatalf("preflight body = %#v", bodies[1]) + } +} + func TestSearchSendsImageURI(t *testing.T) { client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/search/search" { @@ -1330,6 +1639,28 @@ func TestSetTagsDefaultsModeAndOmitsTelemetry(t *testing.T) { } } +func TestSetTagsForwardsExtraAndRejectsOfficialFields(t *testing.T) { + client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readJSONBody(t, r) + if got := body["future_flag"]; got != false { + t.Fatalf("future_flag = %#v", got) + } + writeOK(t, w, map[string]any{"updated": 1}) + })) + defer closeServer() + + if _, err := client.SetTags(context.Background(), "resources/docs", []string{"team=infra"}, &SetTagsOptions{ + Extra: map[string]any{"future_flag": false}, + }); err != nil { + t.Fatal(err) + } + if _, err := client.SetTags(context.Background(), "resources/docs", []string{"team=infra"}, &SetTagsOptions{ + Extra: map[string]any{"uri": "viking://other"}, + }); err == nil { + t.Fatal("expected extra to reject uri override") + } +} + func TestGrepForwardsLevelLimit(t *testing.T) { client, closeServer := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method+" "+r.URL.Path != "POST /api/v1/search/grep" { diff --git a/sdk/go/examples/basic_usage/main.go b/sdk/go/examples/basic_usage/main.go index aa4e1db7af..c4e794ee8c 100644 --- a/sdk/go/examples/basic_usage/main.go +++ b/sdk/go/examples/basic_usage/main.go @@ -85,7 +85,7 @@ func main() { fmt.Println("5. Write and find") _, err = client.Write(ctx, resourceURI, content+"\n\nGo SDK write check.", &openviking.WriteOptions{ Mode: "replace", - Wait: true, + Wait: openviking.Bool(true), }) if err != nil { log.Fatal(err) @@ -93,7 +93,7 @@ func main() { findResult, err := client.Find(ctx, "Go SDK smoke test", &openviking.FindOptions{ TargetURI: "viking://resources/go-sdk-smoke", - Limit: 5, + Limit: openviking.Int(5), }) if err != nil { log.Fatal(err) @@ -292,7 +292,7 @@ func main() { fmt.Printf(" context=%v\n", sessionContext) commit, err := client.CommitSession(ctx, sessionID, &openviking.CommitSessionOptions{ - KeepRecentCount: 0, + KeepRecentCount: openviking.Int(0), }) if err != nil { log.Fatal(err) @@ -320,7 +320,7 @@ func main() { memoryResults, err := client.Find(ctx, memoryMarker, &openviking.FindOptions{ TargetURI: "viking://user/memories", - Limit: 5, + Limit: openviking.Int(5), ContextType: []string{"memory"}, }) if err != nil { @@ -347,7 +347,7 @@ func main() { peerMemoryResults, err := peerClient.Find(ctx, peerMarker, &openviking.FindOptions{ TargetURI: "viking://user/memories", - Limit: 5, + Limit: openviking.Int(5), ContextType: []string{"memory"}, }) if err != nil { diff --git a/sdk/go/filesystem.go b/sdk/go/filesystem.go index bbc546d130..919100f948 100644 --- a/sdk/go/filesystem.go +++ b/sdk/go/filesystem.go @@ -2,6 +2,7 @@ package openviking import ( "context" + "io" "net/http" "net/url" ) @@ -127,6 +128,39 @@ func (c *Client) Read(ctx context.Context, uri string, offset int, limit int) (s return result, err } +// DownloadBytes downloads raw stored bytes. +func (c *Client) DownloadBytes(ctx context.Context, uri string) ([]byte, error) { + query := url.Values{"uri": []string{NormalizeURI(uri)}} + req, err := c.newRequest(ctx, http.MethodGet, "/api/v1/content/download", query, nil) + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + env, decodeErr := decodeEnvelope(resp.StatusCode, data) + if decodeErr != nil { + return nil, decodeErr + } + if env.Error != nil { + return nil, apiError(resp.StatusCode, env.Error) + } + return nil, &Error{ + Code: "UNKNOWN", + Message: envelopeDetail(env, resp.StatusCode, data), + StatusCode: resp.StatusCode, + } + } + return data, nil +} + // Abstract reads L0 abstract content. func (c *Client) Abstract(ctx context.Context, uri string) (string, error) { query := url.Values{"uri": []string{NormalizeURI(uri)}} @@ -146,25 +180,54 @@ func (c *Client) Overview(ctx context.Context, uri string) (string, error) { // Write writes text content and refreshes related semantics/vectors. func (c *Client) Write(ctx context.Context, uri string, content string, opts *WriteOptions) (map[string]any, error) { if opts == nil { - opts = &WriteOptions{Mode: "replace"} - } - mode := opts.Mode - if mode == "" { - mode = "replace" + opts = &WriteOptions{} } payload := map[string]any{ "uri": NormalizeURI(uri), "content": content, - "mode": mode, - "wait": opts.Wait, } + setString(payload, "mode", opts.Mode) + setAny(payload, "wait", opts.Wait) setFloatPtr(payload, "timeout", opts.Timeout) setAny(payload, "telemetry", opts.Telemetry) + setString(payload, "processing_mode", opts.ProcessingMode) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/content/write", nil, payload, &result) return result, err } +// BatchWrite applies preconditioned file writes in one request. +func (c *Client) BatchWrite( + ctx context.Context, + rootURI string, + operations []BatchWriteOperation, + opts *BatchWriteOptions, +) (map[string]any, error) { + normalized := make([]BatchWriteOperation, len(operations)) + copy(normalized, operations) + for i := range normalized { + normalized[i].URI = NormalizeURI(normalized[i].URI) + } + payload := map[string]any{ + "root_uri": NormalizeURI(rootURI), + "operations": normalized, + } + if opts != nil { + setAny(payload, "wait", opts.Wait) + setFloatPtr(payload, "timeout", opts.Timeout) + setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } + } + var result map[string]any + err := c.doJSON(ctx, http.MethodPost, "/api/v1/content/batch-write", nil, payload, &result) + return result, err +} + // SetTags sets explicit k=v retrieval tags metadata for a file or directory. // Valid modes are "replace" (default) and "append"; Recursive applies the tags // to every file under a directory URI. @@ -189,6 +252,9 @@ func (c *Client) SetTags(ctx context.Context, uri string, tags []string, opts *S "recursive": opts.Recursive, } setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtraProtected(payload, opts.Extra, "uri", "tags", "mode", "recursive", "telemetry"); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/fs/attrs/set_tags", nil, payload, &result) return result, err @@ -217,6 +283,9 @@ func (c *Client) Reindex(ctx context.Context, uri string, opts *ReindexOptions) } payload["tag_mode"] = tagMode } + if err := mergeExtraProtected(payload, opts.Extra, "tags", "tag_mode"); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/content/reindex", nil, payload, &result) return result, err diff --git a/sdk/go/helpers.go b/sdk/go/helpers.go index 7807999674..3f667e62ea 100644 --- a/sdk/go/helpers.go +++ b/sdk/go/helpers.go @@ -48,6 +48,33 @@ func setFloatPtr(m map[string]any, key string, value *float64) { } } +func mergeExtra(payload map[string]any, extra map[string]any) error { + return mergeExtraProtected(payload, extra) +} + +func mergeExtraProtected( + payload map[string]any, + extra map[string]any, + protected ...string, +) error { + protectedFields := make(map[string]struct{}, len(protected)) + for _, key := range protected { + protectedFields[key] = struct{}{} + } + for key, value := range extra { + if _, exists := payload[key]; exists { + return fmt.Errorf("openviking: extra cannot override %q", key) + } + if _, exists := protectedFields[key]; exists { + return fmt.Errorf("openviking: extra cannot override %q", key) + } + if value != nil { + payload[key] = value + } + } + return nil +} + func boolValue(ptr *bool, fallback bool) bool { if ptr == nil { return fallback diff --git a/sdk/go/openviking_assets.go b/sdk/go/openviking_assets.go new file mode 100644 index 0000000000..91f8868668 --- /dev/null +++ b/sdk/go/openviking_assets.go @@ -0,0 +1,65 @@ +package openviking + +import ( + "context" + "net/http" +) + +// ResolveOpenVikingAssets parses and validates an OpenViking Assets manifest. +func (c *Client) ResolveOpenVikingAssets( + ctx context.Context, + manifestYAML string, + opts *ResolveAssetsOptions, +) (map[string]any, error) { + payload := map[string]any{"manifest_yaml": manifestYAML} + if opts != nil { + setString(payload, "catalog_yaml", opts.CatalogYAML) + setString(payload, "manifest_label", opts.ManifestLabel) + setString(payload, "catalog_label", opts.CatalogLabel) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } + } + var result map[string]any + err := c.doJSON( + ctx, + http.MethodPost, + "/api/v1/openviking-assets/resolve", + nil, + payload, + &result, + ) + return result, err +} + +// PreflightOpenVikingAsset verifies read access to one Git asset. +func (c *Client) PreflightOpenVikingAsset( + ctx context.Context, + name string, + repoURL string, + opts *PreflightAssetOptions, +) (map[string]any, error) { + payload := map[string]any{ + "name": name, + "connector": "git", + "repo_url": repoURL, + } + if opts != nil { + setString(payload, "branch", opts.Branch) + setString(payload, "commit", opts.Commit) + setAny(payload, "auth_config", opts.AuthConfig) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } + } + var result map[string]any + err := c.doJSON( + ctx, + http.MethodPost, + "/api/v1/openviking-assets/preflight", + nil, + payload, + &result, + ) + return result, err +} diff --git a/sdk/go/resources.go b/sdk/go/resources.go index 5a1890b1c7..070f19a567 100644 --- a/sdk/go/resources.go +++ b/sdk/go/resources.go @@ -24,6 +24,7 @@ func (c *Client) AddResource(ctx context.Context, path string, opts *AddResource } setString(payload, "to", opts.To) setString(payload, "parent", opts.Parent) + setAny(payload, "create_parent", opts.CreateParent) setString(payload, "ignore_dirs", opts.IgnoreDirs) setString(payload, "include", opts.Include) setString(payload, "exclude", opts.Exclude) @@ -49,6 +50,9 @@ func (c *Client) AddResource(ctx context.Context, path string, opts *AddResource if err := c.addLocalUpload(ctx, payload, path, true); err != nil { return nil, err } + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/resources", nil, payload, &result) return result, err diff --git a/sdk/go/retrieval.go b/sdk/go/retrieval.go index 7367b23c5a..362994ad6c 100644 --- a/sdk/go/retrieval.go +++ b/sdk/go/retrieval.go @@ -10,37 +10,34 @@ func (c *Client) Find(ctx context.Context, queryText string, opts *FindOptions) if opts == nil { opts = &FindOptions{} } - limit := opts.Limit - if limit == 0 { - limit = 10 - } - actualLimit := limit - if opts.NodeLimit != nil { - actualLimit = *opts.NodeLimit - } imageURL, err := normalizeImageInput(opts.Image) if err != nil { return nil, err } - payload := map[string]any{ - "query": queryText, - "target_uri": normalizeTarget(opts.TargetURI), - "limit": actualLimit, + payload := map[string]any{"query": queryText} + if opts.TargetURI != nil { + payload["target_uri"] = normalizeTarget(opts.TargetURI) } + setAny(payload, "limit", opts.Limit) + setAny(payload, "node_limit", opts.NodeLimit) setString(payload, "image_url", imageURL) setAny(payload, "score_threshold", opts.ScoreThreshold) setAny(payload, "filter", opts.Filter) setAny(payload, "context_type", opts.ContextType) + setAny(payload, "include_provenance", opts.IncludeProvenance) setString(payload, "since", opts.Since) setString(payload, "until", opts.Until) setString(payload, "time_field", opts.TimeField) - if len(opts.Level) > 0 { + if opts.Level != nil { payload["level"] = opts.Level } - if len(opts.Tags) > 0 { + if opts.Tags != nil { payload["tags"] = opts.Tags } setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result FindResult err = c.doJSON(ctx, http.MethodPost, "/api/v1/search/find", nil, payload, &result) return &result, err @@ -51,43 +48,91 @@ func (c *Client) Search(ctx context.Context, queryText string, opts *SearchOptio if opts == nil { opts = &SearchOptions{} } - limit := opts.Limit - if limit == 0 { - limit = 10 - } - actualLimit := limit - if opts.NodeLimit != nil { - actualLimit = *opts.NodeLimit - } imageURL, err := normalizeImageInput(opts.Image) if err != nil { return nil, err } - payload := map[string]any{ - "query": queryText, - "target_uri": normalizeTarget(opts.TargetURI), - "limit": actualLimit, + payload := map[string]any{"query": queryText} + if opts.TargetURI != nil { + payload["target_uri"] = normalizeTarget(opts.TargetURI) } + setAny(payload, "limit", opts.Limit) + setAny(payload, "node_limit", opts.NodeLimit) setString(payload, "image_url", imageURL) setString(payload, "session_id", opts.SessionID) setAny(payload, "score_threshold", opts.ScoreThreshold) setAny(payload, "filter", opts.Filter) setAny(payload, "context_type", opts.ContextType) + setAny(payload, "include_provenance", opts.IncludeProvenance) setString(payload, "since", opts.Since) setString(payload, "until", opts.Until) setString(payload, "time_field", opts.TimeField) - if len(opts.Level) > 0 { + if opts.Level != nil { payload["level"] = opts.Level } - if len(opts.Tags) > 0 { + if opts.Tags != nil { payload["tags"] = opts.Tags } setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result FindResult err = c.doJSON(ctx, http.MethodPost, "/api/v1/search/search", nil, payload, &result) return &result, err } +// SearchContext assembles injection-ready context on the server. +func (c *Client) SearchContext(ctx context.Context, query string, opts *SearchContextOptions) (*SearchContextResult, error) { + if opts == nil { + opts = &SearchContextOptions{} + } + payload := map[string]any{ + "query": query, + "mode": "context", + } + imageURL, err := normalizeImageInput(opts.Image) + if err != nil { + return nil, err + } + setString(payload, "image_url", imageURL) + setString(payload, "session_id", opts.SessionID) + setAny(payload, "limit", opts.Limit) + setAny(payload, "node_limit", opts.NodeLimit) + setAny(payload, "score_threshold", opts.ScoreThreshold) + setAny(payload, "filter", opts.Filter) + setAny(payload, "context_type", opts.ContextType) + setAny(payload, "include_provenance", opts.IncludeProvenance) + if opts.Tags != nil { + payload["tags"] = opts.Tags + } + setString(payload, "since", opts.Since) + setString(payload, "until", opts.Until) + setString(payload, "time_field", opts.TimeField) + setString(payload, "query_expansion", opts.QueryExpansion) + setAny(payload, "max_tokens", opts.MaxTokens) + if opts.Quotas != nil { + payload["quotas"] = opts.Quotas + } + setString(payload, "purpose", opts.Purpose) + setAny(payload, "detail", opts.Detail) + setAny(payload, "dedup_turns", opts.DedupTurns) + if opts.ExcludeURIs != nil { + payload["exclude_uris"] = opts.ExcludeURIs + } + setString(payload, "peer_scope", opts.PeerScope) + setAny(payload, "other_peer_penalty", opts.OtherPeerPenalty) + setAny(payload, "rewrite", opts.Rewrite) + setAny(payload, "rewrite_max_bullets", opts.RewriteMaxBullets) + setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } + var result SearchContextResult + err = c.doJSON(ctx, http.MethodPost, "/api/v1/search/search", nil, payload, &result) + return &result, err +} + // Grep searches file content by pattern. func (c *Client) Grep(ctx context.Context, uri, pattern string, opts *GrepOptions) (map[string]any, error) { if opts == nil { diff --git a/sdk/go/sessions.go b/sdk/go/sessions.go index 3a98c68228..5aaadf595b 100644 --- a/sdk/go/sessions.go +++ b/sdk/go/sessions.go @@ -23,6 +23,9 @@ func (c *Client) CreateSession(ctx context.Context, opts *CreateSessionOptions) } setAny(payload, "memory_extraction_config", opts.MemoryExtractionConfig) setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/sessions", nil, payload, &result) return result, err @@ -57,6 +60,9 @@ func (c *Client) UpdateSessionConfig(ctx context.Context, sessionID string, opts payload["auto_commit_policy"] = *opts.AutoCommitPolicy } setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPatch, "/api/v1/sessions/"+url.PathEscape(sessionID)+"/config", nil, payload, &result) return result, err @@ -99,18 +105,23 @@ func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { } // AddMessage appends a message to a session. -func (c *Client) AddMessage(ctx context.Context, sessionID, role string, opts AddMessageOptions) (map[string]any, error) { - payload := map[string]any{"role": role} - if len(opts.Parts) > 0 { - payload["parts"] = opts.Parts - } else if opts.Content != nil { - payload["content"] = *opts.Content +func (c *Client) AddMessage(ctx context.Context, sessionID string, message Message) (map[string]any, error) { + payload := map[string]any{"role": message.Role} + if len(message.Parts) > 0 { + payload["parts"] = message.Parts + } else if message.Content != nil { + payload["content"] = *message.Content } else { return nil, fmt.Errorf("openviking: AddMessage requires Content or Parts") } - setString(payload, "created_at", opts.CreatedAt) - setString(payload, "peer_id", opts.PeerID) - setAny(payload, "telemetry", opts.Telemetry) + setString(payload, "created_at", message.CreatedAt) + setString(payload, "peer_id", message.PeerID) + setString(payload, "turn_id", message.TurnID) + setString(payload, "message_kind", message.MessageKind) + if message.SourceMessageIDs != nil { + payload["source_message_ids"] = message.SourceMessageIDs + } + setAny(payload, "telemetry", message.Telemetry) var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/sessions/"+url.PathEscape(sessionID)+"/messages", nil, payload, &result) return result, err @@ -121,6 +132,9 @@ func (c *Client) BatchAddMessages(ctx context.Context, sessionID string, message payload := map[string]any{"messages": messages} if opts != nil { setAny(payload, "telemetry", opts.Telemetry) + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/sessions/"+url.PathEscape(sessionID)+"/messages/batch", nil, payload, &result) @@ -132,15 +146,23 @@ func (c *Client) CommitSession(ctx context.Context, sessionID string, opts *Comm if opts == nil { opts = &CommitSessionOptions{} } - payload := map[string]any{ - "keep_recent_count": opts.KeepRecentCount, + payload := map[string]any{} + if opts.KeepRecentCount != nil { + payload["keep_recent_count"] = *opts.KeepRecentCount } + setString(payload, "retention_mode", opts.RetentionMode) + setAny(payload, "keep_recent_turn_count", opts.KeepRecentTurnCount) + setAny(payload, "retained_message_token_budget", opts.RetainedMessageTokenBudget) + setAny(payload, "min_raw_tail_steps", opts.MinRawTailSteps) setAny(payload, "telemetry", opts.Telemetry) if opts.EventTags != nil { payload["extraction_metadata"] = map[string]any{ "event": map[string]any{"tags": opts.EventTags}, } } + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/sessions/"+url.PathEscape(sessionID)+"/commit", nil, payload, &result) return result, err diff --git a/sdk/go/skills.go b/sdk/go/skills.go index d724cf09f2..f19a8923e6 100644 --- a/sdk/go/skills.go +++ b/sdk/go/skills.go @@ -20,6 +20,9 @@ func (c *Client) AddSkill(ctx context.Context, data any, opts *AddSkillOptions) if err := c.attachSkillData(ctx, payload, data); err != nil { return nil, err } + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPost, "/api/v1/skills", nil, payload, &result) return result, err @@ -121,6 +124,9 @@ func (c *Client) UpdateSkill(ctx context.Context, skillName string, data any, op if err := c.attachSkillData(ctx, payload, data); err != nil { return nil, err } + if err := mergeExtra(payload, opts.Extra); err != nil { + return nil, err + } var result map[string]any err := c.doJSON(ctx, http.MethodPut, "/api/v1/skills/"+url.PathEscape(skillName), nil, payload, &result) return result, err diff --git a/sdk/go/types.go b/sdk/go/types.go index bb88e11084..469619560f 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -24,6 +24,7 @@ type Config struct { type AddResourceOptions struct { To string Parent string + CreateParent *bool Reason string Instruction string Wait bool @@ -39,6 +40,7 @@ type AddResourceOptions struct { Tags []string TagMode string Telemetry any + Extra map[string]any } // AddSkillOptions controls AddSkill. @@ -46,6 +48,7 @@ type AddSkillOptions struct { Wait bool Timeout *float64 Telemetry any + Extra map[string]any // TargetURI scopes the operation to a skills root such as // "viking://agent/skills" (account-shared) or a per-user root. A nil // value omits target_uri and lets the server use its default root. @@ -108,6 +111,7 @@ type UpdateSkillOptions struct { SourceMetadata map[string]any Telemetry any TargetURI any + Extra map[string]any } // DeleteSkillOptions controls DeleteSkill. @@ -171,10 +175,34 @@ type RemoveOptions struct { // WriteOptions controls Write. type WriteOptions struct { - Mode string - Wait bool + Mode string + Wait *bool + Timeout *float64 + Telemetry any + ProcessingMode string + Extra map[string]any +} + +// BatchWritePrecondition protects one batch write operation. +type BatchWritePrecondition struct { + Kind string `json:"kind"` + BaseHash *string `json:"base_hash,omitempty"` +} + +// BatchWriteOperation is one preconditioned file write. +type BatchWriteOperation struct { + URI string `json:"uri"` + Content *string `json:"content,omitempty"` + ContentBase64 *string `json:"content_base64,omitempty"` + Precondition BatchWritePrecondition `json:"precondition"` +} + +// BatchWriteOptions controls BatchWrite. +type BatchWriteOptions struct { + Wait *bool Timeout *float64 Telemetry any + Extra map[string]any } // SetTagsOptions controls SetTags. @@ -182,6 +210,7 @@ type SetTagsOptions struct { Mode string Recursive bool Telemetry any + Extra map[string]any } // ReindexOptions controls Reindex. @@ -193,41 +222,75 @@ type ReindexOptions struct { DryRun bool Tags []string TagMode string + Extra map[string]any } // FindOptions controls Find. type FindOptions struct { - TargetURI any - Image string - Limit int - NodeLimit *int - ScoreThreshold *float64 - Filter map[string]any - ContextType any - Telemetry any - Since string - Until string - TimeField string - Level []int - Tags []string + TargetURI any + Image string + Limit *int + NodeLimit *int + ScoreThreshold *float64 + Filter map[string]any + ContextType any + IncludeProvenance *bool + Telemetry any + Since string + Until string + TimeField string + Level []int + Tags []string + Extra map[string]any } // SearchOptions controls Search. type SearchOptions struct { - TargetURI any - Image string - SessionID string - Limit int - NodeLimit *int - ScoreThreshold *float64 - Filter map[string]any - ContextType any - Telemetry any - Since string - Until string - TimeField string - Level []int - Tags []string + TargetURI any + Image string + SessionID string + Limit *int + NodeLimit *int + ScoreThreshold *float64 + Filter map[string]any + ContextType any + IncludeProvenance *bool + Telemetry any + Since string + Until string + TimeField string + Level []int + Tags []string + Extra map[string]any +} + +// SearchContextOptions controls server-side context assembly. +type SearchContextOptions struct { + Image string + SessionID string + Limit *int + NodeLimit *int + ScoreThreshold *float64 + Filter map[string]any + ContextType any + IncludeProvenance *bool + Tags []string + Since string + Until string + TimeField string + QueryExpansion string + MaxTokens *int + Quotas map[string]int + Purpose string + Detail any + DedupTurns *int + ExcludeURIs []string + PeerScope string + OtherPeerPenalty any + Rewrite any + RewriteMaxBullets *int + Telemetry any + Extra map[string]any } // GrepOptions controls Grep. @@ -251,6 +314,7 @@ type CreateSessionOptions struct { DisableAutoCommit bool MemoryExtractionConfig map[string]any Telemetry any + Extra map[string]any } // GetSessionOptions controls GetSession. @@ -263,36 +327,74 @@ type UpdateSessionConfigOptions struct { MemoryExtractionConfig map[string]any AutoCommitPolicy *map[string]any Telemetry any -} - -// AddMessageOptions controls AddMessage. -type AddMessageOptions struct { - Content *string - Parts []map[string]any - CreatedAt string - PeerID string - Telemetry any + Extra map[string]any } // Message is one session message payload for BatchAddMessages. type Message struct { - Role string `json:"role"` - Content *string `json:"content,omitempty"` - Parts []map[string]any `json:"parts,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - PeerID string `json:"peer_id,omitempty"` + Role string `json:"role"` + Content *string `json:"content,omitempty"` + Parts []map[string]any `json:"parts,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + PeerID string `json:"peer_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` + MessageKind string `json:"message_kind,omitempty"` + SourceMessageIDs []string `json:"source_message_ids,omitempty"` + Telemetry any `json:"telemetry,omitempty"` } // BatchAddMessagesOptions controls BatchAddMessages. type BatchAddMessagesOptions struct { Telemetry any + Extra map[string]any } // CommitSessionOptions controls CommitSession. type CommitSessionOptions struct { - KeepRecentCount int - Telemetry any - EventTags []string + KeepRecentCount *int + RetentionMode string + KeepRecentTurnCount *int + RetainedMessageTokenBudget *int + MinRawTailSteps *int + Telemetry any + EventTags []string + Extra map[string]any +} + +// ExperienceTrajectoryOptions controls trajectory pagination and date filters. +type ExperienceTrajectoryOptions struct { + Limit *int + Offset *int + StartDate string + EndDate string +} + +// ExperienceOutcomeOptions controls outcome date filters. +type ExperienceOutcomeOptions struct { + StartDate string + EndDate string +} + +// ResolveAssetsOptions controls OpenViking Assets manifest resolution. +type ResolveAssetsOptions struct { + CatalogYAML string + ManifestLabel string + CatalogLabel string + Extra map[string]any +} + +// AssetGitAuth is one-shot Git authentication for asset preflight. +type AssetGitAuth struct { + Username string `json:"username,omitempty"` + Token string `json:"token,omitempty"` +} + +// PreflightAssetOptions controls Git asset access checks. +type PreflightAssetOptions struct { + Branch string + Commit string + AuthConfig *AssetGitAuth + Extra map[string]any } // ListTasksOptions controls ListTasks. @@ -329,26 +431,34 @@ type FindResult struct { Total int `json:"total,omitempty"` } -// MatchedContext is one retrieval hit. +// SearchContextEntry is one assembled context entry. +type SearchContextEntry struct { + URI string `json:"uri,omitempty"` + Category string `json:"category,omitempty"` + Score float64 `json:"score,omitempty"` + Detail string `json:"detail,omitempty"` + Text string `json:"text,omitempty"` + Origin string `json:"origin,omitempty"` +} + +// SearchContextResult is an injection-ready context response. +type SearchContextResult struct { + Entries []SearchContextEntry `json:"entries,omitempty"` + Rendered string `json:"rendered,omitempty"` + Digest string `json:"digest,omitempty"` + Stats map[string]any `json:"stats,omitempty"` +} + +// MatchedContext is one retrieval hit. Only the fields the retrieval pipeline +// actually populates are exposed; search_tags is surfaced under the "tags" key +// to match the tags filter parameter accepted by Find and Search. type MatchedContext struct { - URI string `json:"uri,omitempty"` - ContextType string `json:"context_type,omitempty"` - Level int `json:"level,omitempty"` - Abstract string `json:"abstract,omitempty"` - Overview string `json:"overview,omitempty"` - Category string `json:"category,omitempty"` - Score float64 `json:"score,omitempty"` - MatchReason string `json:"match_reason,omitempty"` - Relations []RelatedContext `json:"relations,omitempty"` -} - -// RelatedContext is a related context reference attached to a retrieval hit. -type RelatedContext struct { - URI string `json:"uri,omitempty"` - Reason string `json:"reason,omitempty"` - Score float64 `json:"score,omitempty"` - Relation string `json:"relation,omitempty"` - RelationID string `json:"relation_id,omitempty"` + URI string `json:"uri,omitempty"` + ContextType string `json:"context_type,omitempty"` + Level int `json:"level,omitempty"` + Abstract string `json:"abstract,omitempty"` + Score float64 `json:"score,omitempty"` + Tags []string `json:"tags,omitempty"` } // QueryPlan describes search query expansion details when the server returns them. diff --git a/sdk/python/README.md b/sdk/python/README.md index d7eda9b34e..1d54bb530e 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -96,7 +96,7 @@ client = SyncHTTPClient( client.initialize() with use_actor_peer("assistant-a"): - memories = client.find("deployment preference") + memories = client.find(query="deployment preference") ``` The scope is isolated with Python `ContextVar`, so concurrent async tasks and @@ -124,11 +124,13 @@ client.initialize() healthy = client.health() print("health:", healthy) -session = client.create_session("demo-session") +session = client.create_session(options={"session_id": "demo-session"}) print("session:", session) -client.session("demo-session").add_message("user", "hello from sdk") -context = client.session("demo-session").get_session_context(token_budget=4096) +client.session(session_id="demo-session").add_message( + message={"role": "user", "content": "hello from sdk"} +) +context = client.session(session_id="demo-session").get_session_context(token_budget=4096) print("context:", context) client.close() @@ -152,11 +154,13 @@ async def main() -> None: healthy = await client.health() print("health:", healthy) - session = await client.create_session("demo-session-async") + session = await client.create_session(options={"session_id": "demo-session-async"}) print("session:", session) - session_client = client.session("demo-session-async") - await session_client.add_message("user", "hello from async sdk") + session_client = client.session(session_id="demo-session-async") + await session_client.add_message( + message={"role": "user", "content": "hello from async sdk"} + ) context = await session_client.get_session_context(token_budget=4096) print("context:", context) @@ -181,21 +185,32 @@ event_config = { } } result = client.create_session( - "demo-session", - memory_extraction_config=event_config, + options={ + "session_id": "demo-session", + "memory_extraction_config": event_config, + }, ) # Explicit None disables a server-wide auto-commit default at creation time. -client.create_session("manual-session", auto_commit_policy=None) +client.create_session( + options={"session_id": "manual-session", "auto_commit_policy": None} +) client.update_session_config( - "demo-session", - auto_commit_policy={"message_count_threshold": 25}, - memory_extraction_config={ - "events": {"tags": ["team=search", "channel=app"]} + session_id="demo-session", + options={ + "auto_commit_policy": {"message_count_threshold": 25}, + "memory_extraction_config": { + "events": {"tags": ["team=search", "channel=app"]} + }, }, ) # Explicit None disables automatic commits; omitting the argument leaves it unchanged. -client.update_session_config("demo-session", auto_commit_policy=None) -client.session("demo-session").commit(event_tags=["team=search", "channel=web"]) +client.update_session_config( + session_id="demo-session", + options={"auto_commit_policy": None}, +) +client.session(session_id="demo-session").commit( + options={"event_tags": ["team=search", "channel=web"]} +) # Use event_tags=[] to skip the session defaults for one commit. print(result) ``` @@ -211,10 +226,12 @@ client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() result = client.add_resource( - "/path/to/notes.md", - to="viking://resources/demo-notes", - reason="knowledge import", - wait=True, + path="/path/to/notes.md", + options={ + "to": "viking://resources/demo-notes", + "reason": "knowledge import", + "wait": True, + }, ) print(result) ``` @@ -225,10 +242,12 @@ or refresh `.abstract.md` / `.overview.md`. ```python result = client.add_resource( - "/path/to/notes.md", - to="viking://resources/demo-notes", - processing_mode="vectors_only", - wait=True, + path="/path/to/notes.md", + options={ + "to": "viking://resources/demo-notes", + "processing_mode": "vectors_only", + "wait": True, + }, ) ``` @@ -240,9 +259,9 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() -client.mkdir("viking://resources/demo-dir") -print(client.ls("viking://resources")) -print(client.read("viking://resources/demo-dir/example.md")) +client.mkdir(uri="viking://resources/demo-dir") +print(client.ls(uri="viking://resources")) +print(client.read(uri="viking://resources/demo-dir/example.md")) ``` ### Retrieval @@ -253,15 +272,28 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() -result = client.find("hello", limit=5) +result = client.find(query="hello", options={"limit": 5}) print(result) ``` Image search uses the same methods. Pass a local path, bytes, data URI, HTTP URL, or `viking://` URI with `image`. The server must use a multimodal embedding model. ```python -result = client.find(image="/path/to/photo.png", limit=5) -result = client.search("similar poster", image="viking://resources/poster.png") +result = client.find(query="", options={"image": "/path/to/photo.png", "limit": 5}) +result = client.search( + query="similar poster", + options={"image": "viking://resources/poster.png"}, +) +``` + +Complex requests use typed Options dictionaries. Use the `extra` key only for +server fields that the installed SDK version does not yet expose: + +```python +result = client.find( + query="authentication", + options={"limit": 10, "extra": {"future_server_field": False}}, +) ``` ## Admin Operations @@ -330,7 +362,7 @@ client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() try: - print(client.read("viking://resources/not-exists.md")) + print(client.read(uri="viking://resources/not-exists.md")) except OpenVikingError as exc: print(type(exc).__name__, exc) ``` diff --git a/sdk/python/README_CN.md b/sdk/python/README_CN.md index 28d856f8cc..fd299cb7ac 100644 --- a/sdk/python/README_CN.md +++ b/sdk/python/README_CN.md @@ -96,7 +96,7 @@ client = SyncHTTPClient( client.initialize() with use_actor_peer("assistant-a"): - memories = client.find("部署偏好") + memories = client.find(query="部署偏好") ``` 该作用域通过 Python `ContextVar` 隔离,因此并发 async task 以及由 SDK worker loop @@ -121,11 +121,13 @@ client.initialize() healthy = client.health() print("health:", healthy) -session = client.create_session("demo-session") +session = client.create_session(options={"session_id": "demo-session"}) print("session:", session) -client.session("demo-session").add_message("user", "hello from sdk") -context = client.session("demo-session").get_session_context(token_budget=4096) +client.session(session_id="demo-session").add_message( + message={"role": "user", "content": "hello from sdk"} +) +context = client.session(session_id="demo-session").get_session_context(token_budget=4096) print("context:", context) client.close() @@ -149,11 +151,13 @@ async def main() -> None: healthy = await client.health() print("health:", healthy) - session = await client.create_session("demo-session-async") + session = await client.create_session(options={"session_id": "demo-session-async"}) print("session:", session) - session_client = client.session("demo-session-async") - await session_client.add_message("user", "hello from async sdk") + session_client = client.session(session_id="demo-session-async") + await session_client.add_message( + message={"role": "user", "content": "hello from async sdk"} + ) context = await session_client.get_session_context(token_budget=4096) print("context:", context) @@ -178,21 +182,32 @@ event_config = { } } result = client.create_session( - "demo-session", - memory_extraction_config=event_config, + options={ + "session_id": "demo-session", + "memory_extraction_config": event_config, + }, ) # 创建时显式传 None,可覆盖服务端默认并禁用自动提交。 -client.create_session("manual-session", auto_commit_policy=None) +client.create_session( + options={"session_id": "manual-session", "auto_commit_policy": None} +) client.update_session_config( - "demo-session", - auto_commit_policy={"message_count_threshold": 25}, - memory_extraction_config={ - "events": {"tags": ["team=search", "channel=app"]} + session_id="demo-session", + options={ + "auto_commit_policy": {"message_count_threshold": 25}, + "memory_extraction_config": { + "events": {"tags": ["team=search", "channel=app"]} + }, }, ) # 显式传 None 会禁用自动 commit;省略参数则保持不变。 -client.update_session_config("demo-session", auto_commit_policy=None) -client.session("demo-session").commit(event_tags=["team=search", "channel=web"]) +client.update_session_config( + session_id="demo-session", + options={"auto_commit_policy": None}, +) +client.session(session_id="demo-session").commit( + options={"event_tags": ["team=search", "channel=web"]} +) # 单次 commit 传 event_tags=[] 可显式跳过 session 默认 tags。 print(result) ``` @@ -208,10 +223,12 @@ client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() result = client.add_resource( - "/path/to/notes.md", - to="viking://resources/demo-notes", - reason="knowledge import", - wait=True, + path="/path/to/notes.md", + options={ + "to": "viking://resources/demo-notes", + "reason": "knowledge import", + "wait": True, + }, ) print(result) ``` @@ -221,10 +238,12 @@ print(result) ```python result = client.add_resource( - "/path/to/notes.md", - to="viking://resources/demo-notes", - processing_mode="vectors_only", - wait=True, + path="/path/to/notes.md", + options={ + "to": "viking://resources/demo-notes", + "processing_mode": "vectors_only", + "wait": True, + }, ) ``` @@ -236,9 +255,9 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() -client.mkdir("viking://resources/demo-dir") -print(client.ls("viking://resources")) -print(client.read("viking://resources/demo-dir/example.md")) +client.mkdir(uri="viking://resources/demo-dir") +print(client.ls(uri="viking://resources")) +print(client.read(uri="viking://resources/demo-dir/example.md")) ``` ### 检索 @@ -249,15 +268,28 @@ from openviking_sdk import SyncHTTPClient client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() -result = client.find("hello", limit=5) +result = client.find(query="hello", options={"limit": 5}) print(result) ``` 图片搜索也使用同一组方法。`image` 支持本地路径、bytes、data URI、HTTP URL 或 `viking://` URI;服务端需要使用 multimodal embedding 模型。 ```python -result = client.find(image="/path/to/photo.png", limit=5) -result = client.search("similar poster", image="viking://resources/poster.png") +result = client.find(query="", options={"image": "/path/to/photo.png", "limit": 5}) +result = client.search( + query="similar poster", + options={"image": "viking://resources/poster.png"}, +) +``` + +复杂请求统一使用带类型提示的 Options 字典。只有服务端已经增加、当前 SDK +版本尚未正式暴露的字段才通过 `extra` 临时传递: + +```python +result = client.find( + query="authentication", + options={"limit": 10, "extra": {"future_server_field": False}}, +) ``` ## 管理员操作 @@ -323,7 +355,7 @@ client = SyncHTTPClient(url="http://127.0.0.1:1933", api_key="your-user-key") client.initialize() try: - print(client.read("viking://resources/not-exists.md")) + print(client.read(uri="viking://resources/not-exists.md")) except OpenVikingError as exc: print(type(exc).__name__, exc) ``` diff --git a/sdk/python/openviking_sdk/__init__.py b/sdk/python/openviking_sdk/__init__.py index eba322f357..52d0874250 100644 --- a/sdk/python/openviking_sdk/__init__.py +++ b/sdk/python/openviking_sdk/__init__.py @@ -7,15 +7,57 @@ ResourceExhaustedError, UnimplementedError, ) +from .options import ( + AddResourceOptions, + AddSkillOptions, + BatchAddMessagesOptions, + BatchWriteOptions, + CommitSessionOptions, + CreateSessionOptions, + ExperienceOutcomeOptions, + ExperienceTrajectoryOptions, + FindOptions, + Message, + PreflightAssetOptions, + ReindexOptions, + ResolveAssetsOptions, + SearchContextOptions, + SearchContextResult, + SearchOptions, + SetTagsOptions, + UpdateSessionConfigOptions, + UpdateSkillOptions, + WriteOptions, +) __all__ = [ "AbortedError", + "AddResourceOptions", + "AddSkillOptions", "AsyncHTTPClient", + "BatchAddMessagesOptions", + "BatchWriteOptions", + "CommitSessionOptions", "ConflictError", + "CreateSessionOptions", + "ExperienceOutcomeOptions", + "ExperienceTrajectoryOptions", + "FindOptions", "get_actor_peer_id", + "Message", "OpenVikingError", + "PreflightAssetOptions", + "ReindexOptions", "ResourceExhaustedError", + "ResolveAssetsOptions", + "SearchContextOptions", + "SearchContextResult", + "SearchOptions", + "SetTagsOptions", "SyncHTTPClient", "UnimplementedError", + "UpdateSessionConfigOptions", + "UpdateSkillOptions", "use_actor_peer", + "WriteOptions", ] diff --git a/sdk/python/openviking_sdk/client.py b/sdk/python/openviking_sdk/client.py index 68ea642a0b..f025fe3589 100644 --- a/sdk/python/openviking_sdk/client.py +++ b/sdk/python/openviking_sdk/client.py @@ -9,7 +9,7 @@ import zipfile from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Mapping, Optional, Type, Union from urllib.parse import quote import httpx @@ -39,6 +39,28 @@ UnimplementedError, VLMFailedError, ) +from .options import ( + AddResourceOptions, + AddSkillOptions, + BatchAddMessagesOptions, + BatchWriteOptions, + CommitSessionOptions, + CreateSessionOptions, + ExperienceOutcomeOptions, + ExperienceTrajectoryOptions, + FindOptions, + Message, + PreflightAssetOptions, + ReindexOptions, + ResolveAssetsOptions, + SearchContextOptions, + SearchContextResult, + SearchOptions, + SetTagsOptions, + UpdateSessionConfigOptions, + UpdateSkillOptions, + WriteOptions, +) ERROR_CODE_TO_EXCEPTION = { "INVALID_ARGUMENT": InvalidArgumentError, @@ -135,64 +157,20 @@ def __init__(self, client: "AsyncHTTPClient", session_id: str): async def add_message( self, - role: str, - content: str | None = None, - parts: list[dict] | None = None, - created_at: str | None = None, - peer_id: str | None = None, - turn_id: str | None = None, - message_kind: str | None = None, - source_message_ids: list[str] | None = None, - ) -> Dict[str, Any]: - semantic_kwargs = { - key: value - for key, value in { - "turn_id": turn_id, - "message_kind": message_kind, - "source_message_ids": source_message_ids, - }.items() - if value is not None - } - return await self._client.add_message( - self.session_id, - role=role, - content=content, - parts=parts, - created_at=created_at, - peer_id=peer_id, - **semantic_kwargs, - ) + message: Optional[Message] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return await self._client.add_message(self.session_id, message, **legacy_kwargs) async def batch_add_messages(self, messages: list[dict]) -> Dict[str, Any]: return await self._client.batch_add_messages(self.session_id, messages) async def commit( self, - keep_recent_count: int = 0, - *, - retention_mode: str | None = None, - keep_recent_turn_count: int | None = None, - retained_message_token_budget: int | None = None, - min_raw_tail_steps: int | None = None, - event_tags: list[str] | None = None, - ) -> Dict[str, Any]: - optional_retention = { - key: value - for key, value in { - "retention_mode": retention_mode, - "keep_recent_turn_count": keep_recent_turn_count, - "retained_message_token_budget": retained_message_token_budget, - "min_raw_tail_steps": min_raw_tail_steps, - }.items() - if value is not None - } - if event_tags is not None: - optional_retention["event_tags"] = event_tags - return await self._client.commit_session( - self.session_id, - keep_recent_count=keep_recent_count, - **optional_retention, - ) + options: Optional[CommitSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return await self._client.commit_session(self.session_id, options, **legacy_kwargs) async def delete(self) -> None: await self._client.delete_session(self.session_id) @@ -214,95 +192,27 @@ def __init__(self, client: "SyncHTTPClient", session_id: str): def add_message( self, - role: str, - content: str | None = None, - parts: list[dict] | None = None, - created_at: str | None = None, - peer_id: str | None = None, - turn_id: str | None = None, - message_kind: str | None = None, - source_message_ids: list[str] | None = None, - ) -> Dict[str, Any]: - semantic_kwargs = { - key: value - for key, value in { - "turn_id": turn_id, - "message_kind": message_kind, - "source_message_ids": source_message_ids, - }.items() - if value is not None - } - return self._client.add_message( - self.session_id, - role=role, - content=content, - parts=parts, - created_at=created_at, - peer_id=peer_id, - **semantic_kwargs, - ) + message: Optional[Message] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return self._client.add_message(self.session_id, message, **legacy_kwargs) def batch_add_messages(self, messages: list[dict]) -> Dict[str, Any]: return self._client.batch_add_messages(self.session_id, messages) def commit( self, - telemetry: Any = False, - *, - keep_recent_count: int = 0, - retention_mode: str | None = None, - keep_recent_turn_count: int | None = None, - retained_message_token_budget: int | None = None, - min_raw_tail_steps: int | None = None, - event_tags: list[str] | None = None, - ) -> Dict[str, Any]: - optional_retention = { - key: value - for key, value in { - "retention_mode": retention_mode, - "keep_recent_turn_count": keep_recent_turn_count, - "retained_message_token_budget": retained_message_token_budget, - "min_raw_tail_steps": min_raw_tail_steps, - }.items() - if value is not None - } - if event_tags is not None: - optional_retention["event_tags"] = event_tags - return self._client.commit_session( - self.session_id, - telemetry=telemetry, - keep_recent_count=keep_recent_count, - **optional_retention, - ) + options: Optional[CommitSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return self._client.commit_session(self.session_id, options, **legacy_kwargs) def commit_async( self, - telemetry: Any = False, - *, - keep_recent_count: int = 0, - retention_mode: str | None = None, - keep_recent_turn_count: int | None = None, - retained_message_token_budget: int | None = None, - min_raw_tail_steps: int | None = None, - event_tags: list[str] | None = None, - ) -> Dict[str, Any]: - optional_retention = { - key: value - for key, value in { - "retention_mode": retention_mode, - "keep_recent_turn_count": keep_recent_turn_count, - "retained_message_token_budget": retained_message_token_budget, - "min_raw_tail_steps": min_raw_tail_steps, - }.items() - if value is not None - } - if event_tags is not None: - optional_retention["event_tags"] = event_tags - return self.commit( - telemetry=telemetry, - keep_recent_count=keep_recent_count, - **optional_retention, - ) + options: Optional[CommitSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return self.commit(options, **legacy_kwargs) def delete(self) -> None: self._client.delete_session(self.session_id) @@ -401,9 +311,7 @@ def __init__( self._ldap_username = config.ldap_username self._ldap_password = config.ldap_password self._oidc_token = config.oidc_token - self._event_hooks = { - event: list(hooks) for event, hooks in (event_hooks or {}).items() - } + self._event_hooks = {event: list(hooks) for event, hooks in (event_hooks or {}).items()} self._http: Optional[httpx.AsyncClient] = None self._observer: Optional[_HTTPObserver] = None self._snapshot: Optional["AsyncHTTPSnapshotNamespace"] = None @@ -422,6 +330,7 @@ async def initialize(self) -> None: # LDAP Basic Auth if self._auth_mode == "ldap" and self._ldap_username and self._ldap_password: from .config import get_basic_auth_header + headers["Authorization"] = get_basic_auth_header( self._ldap_username, self._ldap_password ) @@ -569,6 +478,116 @@ def _compact_request_body(body: Dict[str, Any]) -> Dict[str, Any]: compacted[key] = value return compacted + @classmethod + def _normalize_message_payload(cls, message: Mapping[str, Any]) -> Dict[str, Any]: + payload = dict(message) + if payload.get("parts"): + payload.pop("content", None) + else: + payload.pop("parts", None) + if payload.get("content") is None: + raise ValueError("Either content or non-empty parts must be provided") + return cls._compact_request_body(payload) + + @staticmethod + def _merge_legacy_options( + options: Optional[Mapping[str, Any]], + legacy_kwargs: Mapping[str, Any], + options_type: Type[Any], + ) -> Dict[str, Any]: + option_values = dict(options or {}) + allowed = set(options_type.__optional_keys__) | set(options_type.__required_keys__) + + unknown = sorted(set(option_values) - allowed) + if unknown: + raise TypeError( + f"Unknown option '{unknown[0]}' for {options_type.__name__}; " + "use 'extra' for server fields not yet supported by the SDK" + ) + + unsupported = sorted(set(legacy_kwargs) - allowed) + if unsupported: + raise TypeError( + f"unsupported option '{unsupported[0]}' for {options_type.__name__}; " + 'use options["extra"] for server fields not yet modeled by this SDK' + ) + + duplicate = sorted(set(option_values) & set(legacy_kwargs)) + if duplicate: + raise ValueError(f"option '{duplicate[0]}' was provided in both options and kwargs") + + option_values.update(legacy_kwargs) + return option_values + + @classmethod + def _build_options_payload( + cls, + options: Optional[Mapping[str, Any]], + options_type: Type[Any], + *, + fixed: Optional[Mapping[str, Any]] = None, + protected: Optional[set[str]] = None, + ) -> Dict[str, Any]: + option_values = dict(options or {}) + allowed = set(options_type.__optional_keys__) | set(options_type.__required_keys__) + unknown = sorted(set(option_values) - allowed) + if unknown: + raise TypeError( + f"Unknown option '{unknown[0]}' for {options_type.__name__}; " + "use 'extra' for server fields not yet supported by the SDK" + ) + + extra = dict(option_values.pop("extra", {}) or {}) + payload = dict(fixed or {}) + protected_fields = set(payload) | set(protected or ()) + official_fields = allowed - {"extra"} + conflicts = sorted(set(extra) & (official_fields | protected_fields)) + if conflicts: + raise ValueError(f"extra cannot override '{conflicts[0]}'") + + payload.update(option_values) + payload.update(extra) + return cls._compact_request_body(payload) + + @classmethod + def _search_options_payload( + cls, + query: str, + options: Optional[Mapping[str, Any]], + options_type: Type[Any], + *, + fixed: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: + option_values = dict(options or {}) + if "image" in option_values: + option_values["image_url"] = _normalize_image_input(option_values.pop("image")) + if "target_uri" in option_values: + option_values["target_uri"] = cls._normalize_target_uri(option_values["target_uri"]) + if "context_type" in option_values: + option_values["context_type"] = cls._normalize_context_type( + option_values["context_type"] + ) + + allowed = set(options_type.__optional_keys__) | set(options_type.__required_keys__) + allowed.discard("image") + allowed.add("image_url") + proxy_type = type( + f"_{options_type.__name__}Payload", + (), + { + "__optional_keys__": frozenset(allowed), + "__required_keys__": frozenset(), + "__name__": options_type.__name__, + }, + ) + fixed_payload = {"query": query} + fixed_payload.update(fixed or {}) + return cls._build_options_payload( + option_values, + proxy_type, + fixed=fixed_payload, + ) + @staticmethod def _normalize_context_type(context_type: Optional[Any]) -> Optional[Any]: if context_type is None: @@ -677,26 +696,13 @@ async def session_exists(self, session_id: str) -> bool: async def add_resource( self, path: str, - to: Optional[str] = None, - parent: Optional[str] = None, - reason: str = "", - instruction: str = "", - wait: bool = False, - timeout: Optional[float] = None, - strict: bool = False, - ignore_dirs: Optional[str] = None, - include: Optional[str] = None, - exclude: Optional[str] = None, - directly_upload_media: bool = True, - preserve_structure: Optional[bool] = None, - watch_interval: float = 0, - args: Optional[Dict[str, Any]] = None, - telemetry: Any = False, - processing_mode: Optional[str] = None, - add_type: Optional[str] = None, - tags: Optional[List[str]] = None, - tag_mode: str = "replace", + options: Optional[AddResourceOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: + option_values = self._merge_legacy_options(options, legacy_kwargs, AddResourceOptions) + add_type = option_values.get("add_type") + to = option_values.get("to") + parent = option_values.get("parent") if add_type is not None: add_type = add_type.strip() or None if add_type and parent: @@ -706,30 +712,17 @@ async def add_resource( if to and parent: raise ValueError("Cannot specify both 'to' and 'parent' at the same time.") - request_data = { - "add_type": add_type, - "to": to, - "parent": parent, - "reason": reason, - "instruction": instruction, - "wait": wait, - "timeout": timeout, - "strict": strict, - "ignore_dirs": ignore_dirs, - "include": include, - "exclude": exclude, - "directly_upload_media": directly_upload_media, - "watch_interval": watch_interval, - "args": args or {}, - "telemetry": telemetry, - } - if processing_mode is not None: - request_data["processing_mode"] = processing_mode - if tags is not None: - request_data["tags"] = tags - request_data["tag_mode"] = tag_mode - if preserve_structure is not None: - request_data["preserve_structure"] = preserve_structure + if to is not None: + option_values["to"] = VikingURI.normalize(to) + if parent is not None: + option_values["parent"] = VikingURI.normalize(parent) + if add_type is not None: + option_values["add_type"] = add_type + request_data = self._build_options_payload( + option_values, + AddResourceOptions, + protected={"path", "temp_file_id", "source_name"}, + ) path_obj = Path(path) if not add_type and path_obj.exists(): @@ -755,13 +748,17 @@ async def add_resource( async def batch_add_messages( self, session_id: str, - messages: list[dict], - telemetry: Any = False, + messages: list[Message], + options: Optional[BatchAddMessagesOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: session_path = self._path_segment(session_id) - payload: Dict[str, Any] = {"messages": messages} - if telemetry is not False: - payload["telemetry"] = telemetry + normalized_messages = [self._normalize_message_payload(message) for message in messages] + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, BatchAddMessagesOptions), + BatchAddMessagesOptions, + fixed={"messages": normalized_messages}, + ) response = await self._request( "POST", f"/api/v1/sessions/{session_path}/messages/batch", @@ -772,14 +769,17 @@ async def batch_add_messages( async def add_skill( self, data: Any, - wait: bool = False, - timeout: Optional[float] = None, - telemetry: Any = False, - target_uri: Optional[str] = None, + options: Optional[AddSkillOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - request_data = {"wait": wait, "timeout": timeout, "telemetry": telemetry} - if target_uri is not None: - request_data["target_uri"] = target_uri + option_values = self._merge_legacy_options(options, legacy_kwargs, AddSkillOptions) + if "target_uri" in option_values: + option_values["target_uri"] = VikingURI.normalize(option_values["target_uri"]) + request_data = self._build_options_payload( + option_values, + AddSkillOptions, + protected={"data", "temp_file_id"}, + ) if isinstance(data, str): path_obj = Path(data) if path_obj.exists(): @@ -876,20 +876,17 @@ async def update_skill( self, skill_name: str, data: Any, - wait: bool = False, - timeout: Optional[float] = None, - source_metadata: Optional[Dict[str, Any]] = None, - telemetry: Any = False, - target_uri: Optional[str] = None, + options: Optional[UpdateSkillOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - request_data: Dict[str, Any] = { - "wait": wait, - "timeout": timeout, - "source_metadata": source_metadata, - "telemetry": telemetry, - } - if target_uri is not None: - request_data["target_uri"] = target_uri + option_values = self._merge_legacy_options(options, legacy_kwargs, UpdateSkillOptions) + if "target_uri" in option_values: + option_values["target_uri"] = VikingURI.normalize(option_values["target_uri"]) + request_data = self._build_options_payload( + option_values, + UpdateSkillOptions, + protected={"data", "temp_file_id"}, + ) if isinstance(data, str): path_obj = Path(data) if path_obj.exists(): @@ -1165,22 +1162,14 @@ async def write( self, uri: str, content: str, - mode: str = "replace", - wait: bool = False, - timeout: Optional[float] = None, - telemetry: Any = False, - processing_mode: Optional[str] = None, + options: Optional[WriteOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - payload = { - "uri": VikingURI.normalize(uri), - "content": content, - "mode": mode, - "wait": wait, - "timeout": timeout, - "telemetry": telemetry, - } - if processing_mode is not None: - payload["processing_mode"] = processing_mode + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, WriteOptions), + WriteOptions, + fixed={"uri": VikingURI.normalize(uri), "content": content}, + ) response = await self._request( "POST", "/api/v1/content/write", @@ -1192,9 +1181,8 @@ async def batch_write( self, root_uri: str, operations: List[Dict[str, Any]], - wait: bool = True, - timeout: Optional[float] = None, - telemetry: Any = False, + options: Optional[BatchWriteOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: """Apply a preconditioned multi-file content write.""" normalized_operations = [] @@ -1202,16 +1190,21 @@ async def batch_write( item = dict(operation) item["uri"] = VikingURI.normalize(str(item.get("uri") or "")) normalized_operations.append(item) - response = await self._request( - "POST", - "/api/v1/content/batch-write", - json={ + option_values = self._merge_legacy_options(options, legacy_kwargs, BatchWriteOptions) + payload = self._build_options_payload( + option_values, + BatchWriteOptions, + fixed={ "root_uri": VikingURI.normalize(root_uri), "operations": normalized_operations, - "wait": wait, - "timeout": timeout, - "telemetry": telemetry, }, + ) + wait = option_values.get("wait", True) + timeout = option_values.get("timeout") + response = await self._request( + "POST", + "/api/v1/content/batch-write", + json=payload, **self._wait_request_kwargs(wait=wait, timeout=timeout), ) return self._handle_response_data(response).get("result", {}) @@ -1220,82 +1213,61 @@ async def set_tags( self, uri: str, tags: List[str], - mode: str = "replace", - recursive: bool = False, - telemetry: Any = False, + options: Optional[SetTagsOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, SetTagsOptions), + SetTagsOptions, + fixed={"uri": VikingURI.normalize(uri), "tags": tags}, + ) response = await self._request( "POST", "/api/v1/fs/attrs/set_tags", - json={ - "uri": VikingURI.normalize(uri), - "tags": tags, - "mode": mode, - "recursive": recursive, - "telemetry": telemetry, - }, + json=payload, ) return self._handle_response_data(response).get("result", {}) async def find( self, query: str = "", - target_uri: Union[str, List[str]] = "", - limit: int = 10, - node_limit: Optional[int] = None, - score_threshold: Optional[float] = None, - filter: Optional[Dict[str, Any]] = None, - context_type: Optional[Any] = None, - tags: Optional[List[str]] = None, - telemetry: Any = False, - image: Any = None, + options: Optional[FindOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - actual_limit = node_limit if node_limit is not None else limit - payload = { - "query": query, - "image_url": _normalize_image_input(image), - "target_uri": self._normalize_target_uri(target_uri), - "limit": actual_limit, - "score_threshold": score_threshold, - "filter": filter, - "context_type": self._normalize_context_type(context_type), - "tags": tags, - "telemetry": telemetry, - } - payload = self._compact_request_body(payload) + payload = self._search_options_payload( + query, + self._merge_legacy_options(options, legacy_kwargs, FindOptions), + FindOptions, + ) response = await self._request("POST", "/api/v1/search/find", json=payload) return self._handle_response_data(response).get("result", {}) async def search( self, query: str = "", - target_uri: Union[str, List[str]] = "", - session: Optional[Any] = None, - session_id: Optional[str] = None, - limit: int = 10, - node_limit: Optional[int] = None, - score_threshold: Optional[float] = None, - filter: Optional[Dict[str, Any]] = None, - context_type: Optional[Any] = None, - tags: Optional[List[str]] = None, - telemetry: Any = False, - image: Any = None, + options: Optional[SearchOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - actual_limit = node_limit if node_limit is not None else limit - sid = session_id or (session.session_id if session else None) - payload = { - "query": query, - "image_url": _normalize_image_input(image), - "target_uri": self._normalize_target_uri(target_uri), - "session_id": sid, - "limit": actual_limit, - "score_threshold": score_threshold, - "filter": filter, - "context_type": self._normalize_context_type(context_type), - "tags": tags, - "telemetry": telemetry, - } - payload = self._compact_request_body(payload) + payload = self._search_options_payload( + query, + self._merge_legacy_options(options, legacy_kwargs, SearchOptions), + SearchOptions, + ) + response = await self._request("POST", "/api/v1/search/search", json=payload) + return self._handle_response_data(response).get("result", {}) + + async def search_context( + self, + query: str = "", + options: Optional[SearchContextOptions] = None, + **legacy_kwargs: Any, + ) -> SearchContextResult: + payload = self._search_options_payload( + query, + self._merge_legacy_options(options, legacy_kwargs, SearchContextOptions), + SearchContextOptions, + fixed={"mode": "context"}, + ) response = await self._request("POST", "/api/v1/search/search", json=payload) return self._handle_response_data(response).get("result", {}) @@ -1370,23 +1342,13 @@ async def unlink(self, from_uri: str, to_uri: str) -> None: async def create_session( self, - session_id: Optional[str] = None, - telemetry: Any = False, - memory_policy: Optional[Dict[str, Any]] = None, - auto_commit_policy: Any = _SESSION_CONFIG_UNSET, - memory_extraction_config: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - json_body: Dict[str, Any] = {} - if session_id is not None: - json_body["session_id"] = session_id - if memory_policy is not None: - json_body["memory_policy"] = memory_policy - if auto_commit_policy is not _SESSION_CONFIG_UNSET: - json_body["auto_commit_policy"] = auto_commit_policy - if memory_extraction_config is not None: - json_body["memory_extraction_config"] = memory_extraction_config - if telemetry is not False: - json_body["telemetry"] = telemetry + options: Optional[CreateSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + option_values = self._merge_legacy_options(options, legacy_kwargs, CreateSessionOptions) + json_body = self._build_options_payload(option_values, CreateSessionOptions) + if "auto_commit_policy" in option_values: + json_body["auto_commit_policy"] = option_values["auto_commit_policy"] response = await self._request("POST", "/api/v1/sessions", json=json_body) return self._handle_response_data(response).get("result", {}) @@ -1403,18 +1365,15 @@ async def get_session(self, session_id: str, *, auto_create: bool = False) -> Di async def update_session_config( self, session_id: str, - *, - memory_extraction_config: Optional[Dict[str, Any]] = None, - auto_commit_policy: Any = _SESSION_CONFIG_UNSET, - telemetry: Any = False, + options: Optional[UpdateSessionConfigOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - payload: Dict[str, Any] = {} - if memory_extraction_config is not None: - payload["memory_extraction_config"] = memory_extraction_config - if auto_commit_policy is not _SESSION_CONFIG_UNSET: - payload["auto_commit_policy"] = auto_commit_policy - if telemetry is not False: - payload["telemetry"] = telemetry + option_values = self._merge_legacy_options( + options, legacy_kwargs, UpdateSessionConfigOptions + ) + payload = self._build_options_payload(option_values, UpdateSessionConfigOptions) + if "auto_commit_policy" in option_values: + payload["auto_commit_policy"] = option_values["auto_commit_policy"] session_path = self._path_segment(session_id) response = await self._request( "PATCH", @@ -1477,27 +1436,29 @@ async def list_tasks( async def commit_session( self, session_id: str, - telemetry: Any = False, - *, - keep_recent_count: int = 0, - retention_mode: str | None = None, - keep_recent_turn_count: int | None = None, - retained_message_token_budget: int | None = None, - min_raw_tail_steps: int | None = None, - event_tags: list[str] | None = None, - ) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "keep_recent_count": keep_recent_count, - "telemetry": telemetry, - } - optional = { - "retention_mode": retention_mode, - "keep_recent_turn_count": keep_recent_turn_count, - "retained_message_token_budget": retained_message_token_budget, - "min_raw_tail_steps": min_raw_tail_steps, + options: Optional[CommitSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + option_values = self._merge_legacy_options(options, legacy_kwargs, CommitSessionOptions) + event_tags = option_values.pop("event_tags", _SESSION_CONFIG_UNSET) + turn_fields = { + "keep_recent_turn_count", + "retained_message_token_budget", + "min_raw_tail_steps", } - payload.update({key: value for key, value in optional.items() if value is not None}) - if event_tags is not None: + if ( + turn_fields & set(option_values) + and option_values.get("retention_mode") != "turn_budget" + ): + raise ValueError( + "retention_mode='turn_budget' is required when Turn retention fields are set" + ) + payload = self._build_options_payload( + option_values, + CommitSessionOptions, + protected={"extraction_metadata"}, + ) + if event_tags is not _SESSION_CONFIG_UNSET: payload["extraction_metadata"] = {"event": {"tags": event_tags}} session_path = self._path_segment(session_id) response = await self._request( @@ -1510,33 +1471,12 @@ async def commit_session( async def add_message( self, session_id: str, - role: str, - content: str | None = None, - parts: list[dict] | None = None, - created_at: str | None = None, - peer_id: str | None = None, - telemetry: Any = False, - turn_id: str | None = None, - message_kind: str | None = None, - source_message_ids: list[str] | None = None, - ) -> Dict[str, Any]: - payload: Dict[str, Any] = {"role": role} - if parts is not None: - payload["parts"] = parts - elif content is not None: - payload["content"] = content - else: - raise ValueError("Either content or parts must be provided") - optional = { - "created_at": created_at, - "peer_id": peer_id, - "turn_id": turn_id, - "message_kind": message_kind, - "source_message_ids": source_message_ids, - } - payload.update({key: value for key, value in optional.items() if value is not None}) - if telemetry is not False: - payload["telemetry"] = telemetry + message: Optional[Message] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + payload = self._normalize_message_payload( + self._merge_legacy_options(message, legacy_kwargs, Message) + ) session_path = self._path_segment(session_id) response = await self._request( "POST", f"/api/v1/sessions/{session_path}/messages", json=payload @@ -1644,16 +1584,14 @@ async def health(self) -> bool: async def reindex( self, uri: str, - mode: str = "vectors_only", - wait: bool = True, - dry_run: bool = False, - tags: Optional[List[str]] = None, - tag_mode: str = "replace", + options: Optional[ReindexOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - payload = {"uri": uri, "mode": mode, "wait": wait, "dry_run": dry_run} - if tags is not None: - payload["tags"] = tags - payload["tag_mode"] = tag_mode + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, ReindexOptions), + ReindexOptions, + fixed={"uri": VikingURI.normalize(uri)}, + ) response = await self._request( "POST", "/api/v1/content/reindex", @@ -1760,6 +1698,107 @@ async def admin_migrate(self, cleanup: bool = False) -> Dict[str, Any]: response = await self._request("POST", "/api/v1/admin/migrate", json={"action": action}) return self._handle_response(response) + async def admin_get_agent_evolution(self) -> Dict[str, Any]: + """Return the effective Agent Evolution switch for the caller's account.""" + response = await self._request("GET", "/api/v1/admin/agent-evolution") + return self._handle_response(response) + + async def admin_set_agent_evolution(self, enabled: bool) -> Dict[str, Any]: + """Persist and hot-reload Agent Evolution for the caller's account.""" + response = await self._request( + "PUT", "/api/v1/admin/agent-evolution", json={"enabled": enabled} + ) + return self._handle_response(response) + + async def admin_get_account_settings(self, account_id: str) -> Dict[str, Any]: + """Return effective and explicitly overridden settings for one account.""" + response = await self._request("GET", f"/api/v1/admin/accounts/{account_id}/settings") + return self._handle_response(response) + + async def admin_set_account_agent_evolution( + self, account_id: str, enabled: bool + ) -> Dict[str, Any]: + """Update the allowlisted Agent Evolution setting for one account.""" + response = await self._request( + "PATCH", + f"/api/v1/admin/accounts/{account_id}/settings", + json={"agent_evolution": {"enabled": enabled}}, + ) + return self._handle_response(response) + + async def list_experience_trajectories( + self, + experience_uri: str, + options: Optional[ExperienceTrajectoryOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"experience_uri": VikingURI.normalize(experience_uri)} + params.update( + self._merge_legacy_options(options, legacy_kwargs, ExperienceTrajectoryOptions) + ) + response = await self._request( + "GET", + "/api/v1/agent-evolution/experiences/trajectories", + params=params, + ) + return self._handle_response(response) + + async def get_experience_outcomes( + self, + experience_uri: str, + options: Optional[ExperienceOutcomeOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"experience_uri": VikingURI.normalize(experience_uri)} + params.update(self._merge_legacy_options(options, legacy_kwargs, ExperienceOutcomeOptions)) + response = await self._request( + "GET", + "/api/v1/agent-evolution/experiences/outcomes", + params=params, + ) + return self._handle_response(response) + + async def resolve_openviking_assets( + self, + manifest_yaml: str, + options: Optional[ResolveAssetsOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, ResolveAssetsOptions), + ResolveAssetsOptions, + fixed={"manifest_yaml": manifest_yaml}, + ) + response = await self._request( + "POST", + "/api/v1/openviking-assets/resolve", + json=payload, + ) + return self._handle_response(response) + + async def preflight_openviking_asset( + self, + name: str, + repo_url: str, + options: Optional[PreflightAssetOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + payload = self._build_options_payload( + self._merge_legacy_options(options, legacy_kwargs, PreflightAssetOptions), + PreflightAssetOptions, + fixed={ + "name": name, + "connector": "git", + "repo_url": repo_url, + }, + ) + response = await self._request( + "POST", + "/api/v1/openviking-assets/preflight", + json=payload, + ) + return self._handle_response(response) + def get_status(self) -> Dict[str, Any]: return run_async(self._get_system_status()) @@ -1939,78 +1978,29 @@ def session_exists(self, session_id: str) -> bool: def add_resource( self, path: str, - to: Optional[str] = None, - parent: Optional[str] = None, - reason: str = "", - instruction: str = "", - wait: bool = False, - timeout: Optional[float] = None, - strict: bool = False, - ignore_dirs: Optional[str] = None, - include: Optional[str] = None, - exclude: Optional[str] = None, - directly_upload_media: bool = True, - preserve_structure: Optional[bool] = None, - watch_interval: float = 0, - args: Optional[Dict[str, Any]] = None, - telemetry: Any = False, - processing_mode: Optional[str] = None, - add_type: Optional[str] = None, - tags: Optional[List[str]] = None, - tag_mode: str = "replace", + options: Optional[AddResourceOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - return run_async( - self._async_client.add_resource( - path=path, - add_type=add_type, - to=to, - parent=parent, - reason=reason, - instruction=instruction, - wait=wait, - timeout=timeout, - strict=strict, - ignore_dirs=ignore_dirs, - include=include, - exclude=exclude, - directly_upload_media=directly_upload_media, - preserve_structure=preserve_structure, - watch_interval=watch_interval, - processing_mode=processing_mode, - args=args, - tags=tags, - tag_mode=tag_mode, - telemetry=telemetry, - ) - ) + return run_async(self._async_client.add_resource(path, options, **legacy_kwargs)) def batch_add_messages( self, session_id: str, - messages: list[dict], - telemetry: Any = False, + messages: list[Message], + options: Optional[BatchAddMessagesOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - if telemetry is False: - return run_async(self._async_client.batch_add_messages(session_id, messages)) - return run_async(self._async_client.batch_add_messages(session_id, messages, telemetry)) + return run_async( + self._async_client.batch_add_messages(session_id, messages, options, **legacy_kwargs) + ) def add_skill( self, data: Any, - wait: bool = False, - timeout: Optional[float] = None, - telemetry: Any = False, - target_uri: Optional[str] = None, + options: Optional[AddSkillOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - return run_async( - self._async_client.add_skill( - data, - wait=wait, - timeout=timeout, - telemetry=telemetry, - target_uri=target_uri, - ) - ) + return run_async(self._async_client.add_skill(data, options, **legacy_kwargs)) def list_skills( self, @@ -2083,22 +2073,11 @@ def update_skill( self, skill_name: str, data: Any, - wait: bool = False, - timeout: Optional[float] = None, - source_metadata: Optional[Dict[str, Any]] = None, - telemetry: Any = False, - target_uri: Optional[str] = None, + options: Optional[UpdateSkillOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: return run_async( - self._async_client.update_skill( - skill_name, - data, - wait=wait, - timeout=timeout, - source_metadata=source_metadata, - telemetry=telemetry, - target_uri=target_uri, - ) + self._async_client.update_skill(skill_name, data, options, **legacy_kwargs) ) def delete_skill( @@ -2246,121 +2225,54 @@ def write( self, uri: str, content: str, - mode: str = "replace", - wait: bool = False, - timeout: Optional[float] = None, - telemetry: Any = False, - processing_mode: Optional[str] = None, + options: Optional[WriteOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - return run_async( - self._async_client.write( - uri=uri, - content=content, - mode=mode, - wait=wait, - timeout=timeout, - telemetry=telemetry, - processing_mode=processing_mode, - ) - ) + return run_async(self._async_client.write(uri, content, options, **legacy_kwargs)) def batch_write( self, root_uri: str, operations: List[Dict[str, Any]], - wait: bool = True, - timeout: Optional[float] = None, - telemetry: Any = False, + options: Optional[BatchWriteOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: return run_async( - self._async_client.batch_write( - root_uri=root_uri, - operations=operations, - wait=wait, - timeout=timeout, - telemetry=telemetry, - ) + self._async_client.batch_write(root_uri, operations, options, **legacy_kwargs) ) def set_tags( self, uri: str, tags: List[str], - mode: str = "replace", - recursive: bool = False, - telemetry: Any = False, + options: Optional[SetTagsOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - return run_async( - self._async_client.set_tags( - uri=uri, - tags=tags, - mode=mode, - recursive=recursive, - telemetry=telemetry, - ) - ) + return run_async(self._async_client.set_tags(uri, tags, options, **legacy_kwargs)) def find( self, query: str = "", - target_uri: Union[str, List[str]] = "", - limit: int = 10, - node_limit: Optional[int] = None, - score_threshold: Optional[float] = None, - filter: Optional[Dict[str, Any]] = None, - context_type: Optional[Any] = None, - tags: Optional[List[str]] = None, - telemetry: Any = False, - image: Any = None, + options: Optional[FindOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - return run_async( - self._async_client.find( - query=query, - target_uri=target_uri, - limit=limit, - node_limit=node_limit, - score_threshold=score_threshold, - filter=filter, - context_type=context_type, - tags=tags, - telemetry=telemetry, - image=image, - ) - ) + return run_async(self._async_client.find(query, options, **legacy_kwargs)) def search( self, query: str = "", - target_uri: Union[str, List[str]] = "", - session: Optional[Any] = None, - session_id: Optional[str] = None, - limit: int = 10, - node_limit: Optional[int] = None, - score_threshold: Optional[float] = None, - filter: Optional[Dict[str, Any]] = None, - context_type: Optional[Any] = None, - tags: Optional[List[str]] = None, - telemetry: Any = False, - image: Any = None, + options: Optional[SearchOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - actual_session_id = session_id - if actual_session_id is None and session is not None: - actual_session_id = getattr(session, "session_id", None) - return run_async( - self._async_client.search( - query=query, - target_uri=target_uri, - session_id=actual_session_id, - limit=limit, - node_limit=node_limit, - score_threshold=score_threshold, - filter=filter, - context_type=context_type, - tags=tags, - telemetry=telemetry, - image=image, - ) - ) + return run_async(self._async_client.search(query, options, **legacy_kwargs)) + + def search_context( + self, + query: str = "", + options: Optional[SearchContextOptions] = None, + **legacy_kwargs: Any, + ) -> SearchContextResult: + return run_async(self._async_client.search_context(query, options, **legacy_kwargs)) def grep( self, @@ -2399,21 +2311,10 @@ def unlink(self, from_uri: str, to_uri: str) -> None: def create_session( self, - session_id: Optional[str] = None, - telemetry: Any = False, - memory_policy: Optional[Dict[str, Any]] = None, - auto_commit_policy: Any = _SESSION_CONFIG_UNSET, - memory_extraction_config: Optional[Dict[str, Any]] = None, + options: Optional[CreateSessionOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - kwargs: Dict[str, Any] = { - "session_id": session_id, - "telemetry": telemetry, - "memory_policy": memory_policy, - "memory_extraction_config": memory_extraction_config, - } - if auto_commit_policy is not _SESSION_CONFIG_UNSET: - kwargs["auto_commit_policy"] = auto_commit_policy - return run_async(self._async_client.create_session(**kwargs)) + return run_async(self._async_client.create_session(options, **legacy_kwargs)) def list_sessions(self) -> List[Any]: return run_async(self._async_client.list_sessions()) @@ -2424,18 +2325,12 @@ def get_session(self, session_id: str, *, auto_create: bool = False) -> Dict[str def update_session_config( self, session_id: str, - *, - memory_extraction_config: Optional[Dict[str, Any]] = None, - auto_commit_policy: Any = _SESSION_CONFIG_UNSET, - telemetry: Any = False, + options: Optional[UpdateSessionConfigOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - kwargs: Dict[str, Any] = { - "memory_extraction_config": memory_extraction_config, - "telemetry": telemetry, - } - if auto_commit_policy is not _SESSION_CONFIG_UNSET: - kwargs["auto_commit_policy"] = auto_commit_policy - return run_async(self._async_client.update_session_config(session_id, **kwargs)) + return run_async( + self._async_client.update_session_config(session_id, options, **legacy_kwargs) + ) def get_session_context(self, session_id: str, token_budget: int = 128_000) -> Dict[str, Any]: return run_async(self._async_client.get_session_context(session_id, token_budget)) @@ -2471,84 +2366,18 @@ def list_tasks( def commit_session( self, session_id: str, - telemetry: Any = False, - *, - keep_recent_count: int = 0, - retention_mode: str | None = None, - keep_recent_turn_count: int | None = None, - retained_message_token_budget: int | None = None, - min_raw_tail_steps: int | None = None, - event_tags: list[str] | None = None, - ) -> Dict[str, Any]: - kwargs = {"keep_recent_count": keep_recent_count} - kwargs.update( - { - key: value - for key, value in { - "retention_mode": retention_mode, - "keep_recent_turn_count": keep_recent_turn_count, - "retained_message_token_budget": retained_message_token_budget, - "min_raw_tail_steps": min_raw_tail_steps, - }.items() - if value is not None - } - ) - if event_tags is not None: - kwargs["event_tags"] = event_tags - if telemetry is False: - return run_async( - self._async_client.commit_session( - session_id, - **kwargs, - ) - ) - return run_async( - self._async_client.commit_session( - session_id, - telemetry=telemetry, - **kwargs, - ) - ) + options: Optional[CommitSessionOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async(self._async_client.commit_session(session_id, options, **legacy_kwargs)) def add_message( self, session_id: str, - role: str, - content: str | None = None, - parts: list[dict] | None = None, - created_at: str | None = None, - peer_id: str | None = None, - telemetry: Any = False, - turn_id: str | None = None, - message_kind: str | None = None, - source_message_ids: list[str] | None = None, - ) -> Dict[str, Any]: - kwargs = { - "role": role, - "content": content, - "parts": parts, - "created_at": created_at, - "peer_id": peer_id, - } - kwargs.update( - { - key: value - for key, value in { - "turn_id": turn_id, - "message_kind": message_kind, - "source_message_ids": source_message_ids, - }.items() - if value is not None - } - ) - if telemetry is not False: - kwargs["telemetry"] = telemetry - return run_async( - self._async_client.add_message( - session_id, - **kwargs, - ) - ) + message: Optional[Message] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async(self._async_client.add_message(session_id, message, **legacy_kwargs)) def export_ovpack( self, @@ -2600,22 +2429,10 @@ def health(self) -> bool: def reindex( self, uri: str, - mode: str = "vectors_only", - wait: bool = True, - dry_run: bool = False, - tags: Optional[List[str]] = None, - tag_mode: str = "replace", + options: Optional[ReindexOptions] = None, + **legacy_kwargs: Any, ) -> Dict[str, Any]: - kwargs = { - "uri": uri, - "mode": mode, - "wait": wait, - "dry_run": dry_run, - } - if tags is not None: - kwargs["tags"] = tags - kwargs["tag_mode"] = tag_mode - return run_async(self._async_client.reindex(**kwargs)) + return run_async(self._async_client.reindex(uri, options, **legacy_kwargs)) def admin_create_account( self, @@ -2674,6 +2491,61 @@ def admin_regenerate_key( def admin_migrate(self, cleanup: bool = False) -> Dict[str, Any]: return run_async(self._async_client.admin_migrate(cleanup=cleanup)) + def admin_get_agent_evolution(self) -> Dict[str, Any]: + return run_async(self._async_client.admin_get_agent_evolution()) + + def admin_set_agent_evolution(self, enabled: bool) -> Dict[str, Any]: + return run_async(self._async_client.admin_set_agent_evolution(enabled)) + + def admin_get_account_settings(self, account_id: str) -> Dict[str, Any]: + return run_async(self._async_client.admin_get_account_settings(account_id)) + + def admin_set_account_agent_evolution(self, account_id: str, enabled: bool) -> Dict[str, Any]: + return run_async(self._async_client.admin_set_account_agent_evolution(account_id, enabled)) + + def list_experience_trajectories( + self, + experience_uri: str, + options: Optional[ExperienceTrajectoryOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async( + self._async_client.list_experience_trajectories( + experience_uri, options, **legacy_kwargs + ) + ) + + def get_experience_outcomes( + self, + experience_uri: str, + options: Optional[ExperienceOutcomeOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async( + self._async_client.get_experience_outcomes(experience_uri, options, **legacy_kwargs) + ) + + def resolve_openviking_assets( + self, + manifest_yaml: str, + options: Optional[ResolveAssetsOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async( + self._async_client.resolve_openviking_assets(manifest_yaml, options, **legacy_kwargs) + ) + + def preflight_openviking_asset( + self, + name: str, + repo_url: str, + options: Optional[PreflightAssetOptions] = None, + **legacy_kwargs: Any, + ) -> Dict[str, Any]: + return run_async( + self._async_client.preflight_openviking_asset(name, repo_url, options, **legacy_kwargs) + ) + def get_status(self) -> Dict[str, Any]: return self._async_client.get_status() @@ -2863,9 +2735,7 @@ def diff( 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) - ) + 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/openviking_sdk/options.py b/sdk/python/openviking_sdk/options.py new file mode 100644 index 0000000000..8a3cde36dd --- /dev/null +++ b/sdk/python/openviking_sdk/options.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Mapping, Optional, TypedDict, Union + +ExtraFields = Mapping[str, Any] +TargetURI = Union[str, List[str]] +Level = Union[int, str, List[int]] +TimeField = Literal["updated_at", "created_at"] +ProcessingMode = Literal["semantic_and_vectors", "vectors_only"] + + +class _ExtraOptions(TypedDict, total=False): + extra: ExtraFields + + +class FindOptions(_ExtraOptions, total=False): + target_uri: TargetURI + image: Any + limit: int + node_limit: int + score_threshold: float + filter: Dict[str, Any] + context_type: Any + include_provenance: bool + tags: List[str] + since: str + until: str + time_field: TimeField + level: Level + telemetry: Any + + +class SearchOptions(FindOptions, total=False): + session_id: str + + +class SearchContextOptions(_ExtraOptions, total=False): + image: Any + session_id: str + limit: int + node_limit: int + score_threshold: float + filter: Dict[str, Any] + context_type: Any + include_provenance: bool + tags: List[str] + since: str + until: str + time_field: TimeField + query_expansion: Literal["off", "auto"] + max_tokens: int + quotas: Dict[str, int] + purpose: Literal["chat", "coding"] + detail: Union[str, Dict[str, str]] + dedup_turns: int + exclude_uris: List[str] + peer_scope: Literal["actor", "all"] + other_peer_penalty: Union[float, Dict[str, float]] + rewrite: Union[bool, Literal["auto"]] + rewrite_max_bullets: int + telemetry: Any + + +class AddResourceOptions(_ExtraOptions, total=False): + to: str + parent: str + create_parent: bool + reason: str + instruction: str + wait: bool + timeout: float + strict: bool + ignore_dirs: str + include: str + exclude: str + directly_upload_media: bool + preserve_structure: bool + watch_interval: float + args: Dict[str, Any] + telemetry: Any + processing_mode: ProcessingMode + add_type: str + tags: List[str] + tag_mode: Literal["replace", "append"] + + +class AddSkillOptions(_ExtraOptions, total=False): + wait: bool + timeout: float + telemetry: Any + target_uri: str + + +class UpdateSkillOptions(AddSkillOptions, total=False): + source_metadata: Dict[str, Any] + + +class WriteOptions(_ExtraOptions, total=False): + mode: str + wait: bool + timeout: float + telemetry: Any + processing_mode: ProcessingMode + + +class BatchWriteOptions(_ExtraOptions, total=False): + wait: bool + timeout: float + telemetry: Any + + +class SetTagsOptions(_ExtraOptions, total=False): + mode: Literal["replace", "append"] + recursive: bool + telemetry: Any + + +class ReindexOptions(_ExtraOptions, total=False): + mode: str + wait: bool + dry_run: bool + tags: List[str] + tag_mode: Literal["replace", "append"] + + +class CreateSessionOptions(_ExtraOptions, total=False): + session_id: str + memory_policy: Dict[str, Any] + auto_commit_policy: Optional[Dict[str, Any]] + memory_extraction_config: Dict[str, Any] + telemetry: Any + + +class UpdateSessionConfigOptions(_ExtraOptions, total=False): + auto_commit_policy: Optional[Dict[str, Any]] + memory_extraction_config: Dict[str, Any] + telemetry: Any + + +class _RequiredMessage(TypedDict): + role: str + + +class Message(_RequiredMessage, total=False): + content: str + parts: List[Dict[str, Any]] + created_at: str + peer_id: str + turn_id: str + message_kind: Literal["user_query", "assistant_step", "tool_transport", "checkpoint"] + source_message_ids: List[str] + telemetry: Any + + +class BatchAddMessagesOptions(_ExtraOptions, total=False): + telemetry: Any + + +class CommitSessionOptions(_ExtraOptions, total=False): + keep_recent_count: int + retention_mode: Literal["turn_budget"] + keep_recent_turn_count: int + retained_message_token_budget: int + min_raw_tail_steps: int + event_tags: List[str] + telemetry: Any + + +class ExperienceTrajectoryOptions(TypedDict, total=False): + limit: int + offset: int + start_date: str + end_date: str + + +class ExperienceOutcomeOptions(TypedDict, total=False): + start_date: str + end_date: str + + +class ResolveAssetsOptions(_ExtraOptions, total=False): + catalog_yaml: str + manifest_label: str + catalog_label: str + + +class AssetGitAuth(TypedDict, total=False): + username: str + token: str + + +class PreflightAssetOptions(_ExtraOptions, total=False): + branch: str + commit: str + auth_config: AssetGitAuth + + +class SearchContextEntry(TypedDict, total=False): + uri: str + category: str + score: float + detail: str + text: str + origin: str + + +class SearchContextResult(TypedDict, total=False): + entries: List[SearchContextEntry] + rendered: str + digest: str + stats: Dict[str, Any] diff --git a/sdk/python/tests/test_async_client_behaviors.py b/sdk/python/tests/test_async_client_behaviors.py index 7cc4cd609d..d2049b6bc8 100644 --- a/sdk/python/tests/test_async_client_behaviors.py +++ b/sdk/python/tests/test_async_client_behaviors.py @@ -1,4 +1,3 @@ -import inspect from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -9,13 +8,6 @@ from openviking_sdk.errors import NotFoundError -def test_add_resource_signatures_keep_telemetry_position(): - for func in (AsyncHTTPClient.add_resource, SyncHTTPClient.add_resource): - params = list(inspect.signature(func).parameters) - assert params.index("telemetry") < params.index("tags") - assert params.index("telemetry") < params.index("tag_mode") - - @pytest.mark.asyncio async def test_async_http_client_initialize_forwards_event_hooks(): async def request_hook(_request): @@ -104,18 +96,22 @@ async def test_async_http_client_sends_message_semantics_and_turn_retention(): await client.add_message( "demo-session", - "assistant", - parts=[{"type": "text", "text": "checking"}], - turn_id="turn-1", - message_kind="assistant_step", - source_message_ids=["u1"], + { + "role": "assistant", + "parts": [{"type": "text", "text": "checking"}], + "turn_id": "turn-1", + "message_kind": "assistant_step", + "source_message_ids": ["u1"], + }, ) await client.commit_session( "demo-session", - retention_mode="turn_budget", - keep_recent_turn_count=3, - retained_message_token_budget=12_000, - min_raw_tail_steps=1, + { + "retention_mode": "turn_budget", + "keep_recent_turn_count": 3, + "retained_message_token_budget": 12_000, + "min_raw_tail_steps": 1, + }, ) assert fake_http.post.await_args_list[0].kwargs["json"] == { @@ -126,8 +122,6 @@ async def test_async_http_client_sends_message_semantics_and_turn_retention(): "source_message_ids": ["u1"], } assert fake_http.post.await_args_list[1].kwargs["json"] == { - "keep_recent_count": 0, - "telemetry": False, "retention_mode": "turn_budget", "keep_recent_turn_count": 3, "retained_message_token_budget": 12_000, @@ -146,15 +140,19 @@ async def test_async_http_client_sends_event_memory_tag_configuration(): client._handle_response_data = lambda _response: {"result": {"status": "ok"}} config = {"events": {"tags": ["team=search", "channel=web"]}} - await client.create_session("tagged-session", memory_extraction_config=config) + await client.create_session( + {"session_id": "tagged-session", "memory_extraction_config": config} + ) await client.update_session_config( "tagged-session", - memory_extraction_config=config, - auto_commit_policy={"message_count_threshold": 25}, + { + "memory_extraction_config": config, + "auto_commit_policy": {"message_count_threshold": 25}, + }, ) - await client.commit_session("tagged-session", event_tags=[]) - await client.update_session_config("tagged-session", auto_commit_policy=None) - await client.create_session("disabled-session", auto_commit_policy=None) + await client.commit_session("tagged-session", {"event_tags": []}) + await client.update_session_config("tagged-session", {"auto_commit_policy": None}) + await client.create_session({"session_id": "disabled-session", "auto_commit_policy": None}) assert fake_http.post.await_args_list[0].kwargs["json"] == { "session_id": "tagged-session", @@ -166,8 +164,6 @@ async def test_async_http_client_sends_event_memory_tag_configuration(): "auto_commit_policy": {"message_count_threshold": 25}, } assert fake_http.post.await_args_list[1].kwargs["json"] == { - "keep_recent_count": 0, - "telemetry": False, "extraction_metadata": {"event": {"tags": []}}, } assert fake_http.patch.await_args_list[1].args == ("/api/v1/sessions/tagged-session/config",) @@ -187,9 +183,7 @@ async def test_async_http_client_reindex_posts_content_reindex(): result = await client.reindex( "viking://resources/demo", - mode="prune_orphans", - wait=False, - dry_run=True, + {"mode": "prune_orphans", "wait": False, "dry_run": True}, ) assert result == {"status": "completed"} @@ -213,8 +207,7 @@ async def test_async_http_client_reindex_sends_explicit_empty_tags(): await client.reindex( "viking://resources/demo", - tags=[], - tag_mode="replace", + {"tags": [], "tag_mode": "replace"}, ) assert fake_http.post.await_args.kwargs["json"]["tags"] == [] @@ -233,7 +226,7 @@ async def test_async_http_client_write_forwards_processing_mode(): await client.write( "viking://resources/demo.md", "updated", - processing_mode="vectors_only", + {"processing_mode": "vectors_only"}, ) payload = fake_http.post.await_args.kwargs["json"] @@ -282,18 +275,14 @@ def test_sync_http_client_reindex_forwards_to_async_client(): ) as mock_run: result = client.reindex( "viking://resources/demo", - mode="prune_orphans", - wait=False, - dry_run=True, + {"mode": "prune_orphans", "wait": False, "dry_run": True}, ) assert result == {"status": "accepted"} assert mock_run.called mock_reindex.assert_called_once_with( - uri="viking://resources/demo", - mode="prune_orphans", - wait=False, - dry_run=True, + "viking://resources/demo", + {"mode": "prune_orphans", "wait": False, "dry_run": True}, ) @@ -322,7 +311,7 @@ def test_sync_http_client_batch_add_messages_forwards_to_async_client(): assert result == {"session_id": "batch-session", "message_count": 2, "added": 2} assert mock_run.called - mock_batch.assert_called_once_with("batch-session", messages) + mock_batch.assert_called_once_with("batch-session", messages, None) def test_sync_http_client_session_returns_sync_session_wrapper(): @@ -347,17 +336,13 @@ def test_sync_session_add_message_wraps_async_client(): "openviking_sdk.client.run_async", return_value={"message_id": "msg-1"}, ) as mock_run: - result = session.add_message("user", content="hello") + result = session.add_message({"role": "user", "content": "hello"}) assert result == {"message_id": "msg-1"} assert mock_run.called mock_add_message.assert_called_once_with( "demo-session", - role="user", - content="hello", - parts=None, - created_at=None, - peer_id=None, + {"role": "user", "content": "hello"}, ) @@ -379,13 +364,13 @@ def test_sync_session_commit_and_context_are_sync(): "openviking_sdk.client.run_async", side_effect=[{"status": "completed"}, {"messages": []}], ) as mock_run: - commit_result = session.commit(keep_recent_count=1) + commit_result = session.commit({"keep_recent_count": 1}) context_result = session.get_session_context(2048) assert commit_result == {"status": "completed"} assert context_result == {"messages": []} assert mock_run.call_count == 2 - mock_commit.assert_called_once_with("demo-session", keep_recent_count=1) + mock_commit.assert_called_once_with("demo-session", {"keep_recent_count": 1}) mock_context.assert_called_once_with("demo-session", 2048) @@ -459,10 +444,10 @@ def test_sync_session_commit_async_and_repr_match_sync_usage(): session = client.session("demo-session") with patch.object(session, "commit", return_value={"status": "completed"}) as mock_commit: - result = session.commit_async(keep_recent_count=3) + result = session.commit_async({"keep_recent_count": 3}) assert result == {"status": "completed"} - mock_commit.assert_called_once_with(telemetry=False, keep_recent_count=3) + mock_commit.assert_called_once_with({"keep_recent_count": 3}) assert "demo-session" in repr(session) @@ -491,21 +476,101 @@ async def test_write_omits_removed_semantic_flags_from_http_payload(): "result": {"uri": "viking://resources/demo.md"} } - await client.write("viking://resources/demo.md", "updated", wait=True) + await client.write("viking://resources/demo.md", "updated", {"wait": True}) fake_http.post.assert_awaited_once_with( "/api/v1/content/write", json={ "uri": "viking://resources/demo.md", "content": "updated", - "mode": "replace", "wait": True, - "timeout": None, - "telemetry": False, }, ) +@pytest.mark.asyncio +async def test_find_forwards_level_and_time_filters_when_provided(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response_data = lambda _response: {"result": {}} + + await client.find( + "hello", + { + "level": [0, 1], + "since": "2026-01-01", + "until": "2026-02-01", + "time_field": "updated_at", + }, + ) + + payload = client._request.await_args.kwargs["json"] + assert payload["level"] == [0, 1] + assert payload["since"] == "2026-01-01" + assert payload["until"] == "2026-02-01" + assert payload["time_field"] == "updated_at" + + +@pytest.mark.asyncio +async def test_find_omits_level_and_time_filters_when_absent(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response_data = lambda _response: {"result": {}} + + await client.find("hello") + + payload = client._request.await_args.kwargs["json"] + for key in ("level", "since", "until", "time_field"): + assert key not in payload + + +@pytest.mark.asyncio +async def test_search_forwards_level_zero_and_omits_unset_time_filters(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response_data = lambda _response: {"result": {}} + + # level=0 is a valid level and must survive compaction (is-None check, not falsy). + await client.search("hello", {"session_id": "s1", "level": 0}) + + payload = client._request.await_args.kwargs["json"] + assert payload["level"] == 0 + assert payload["session_id"] == "s1" + for key in ("since", "until", "time_field"): + assert key not in payload + + +@pytest.mark.asyncio +async def test_find_extra_forwards_unknown_fields_to_payload(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response_data = lambda _response: {"result": {}} + + # The escape hatch lets callers reach server fields the SDK does not yet + # model, without waiting for an SDK release. + await client.find("hello", {"include_provenance": True}) + + payload = client._request.await_args.kwargs["json"] + assert payload["include_provenance"] is True + + +@pytest.mark.asyncio +async def test_write_extra_forwards_unknown_fields_to_payload(): + client = AsyncHTTPClient(url="http://localhost:1933") + fake_http = SimpleNamespace(post=AsyncMock(return_value=object())) + client._http = fake_http + client._handle_response_data = lambda _response: {"result": {}} + + await client.write( + "viking://resources/demo.md", + "body", + {"extra": {"future_flag": 1}}, + ) + + payload = fake_http.post.await_args.kwargs["json"] + assert payload["future_flag"] == 1 + + @pytest.mark.asyncio async def test_add_skill_uploads_local_file_even_when_url_is_localhost(tmp_path): skill_file = tmp_path / "SKILL.md" @@ -544,7 +609,10 @@ async def fake_upload(_path: str) -> str: "result": {"root_uri": "viking://resources/demo"} } - await client.add_resource(str(resource_file), reason="test", watch_interval=60) + await client.add_resource( + str(resource_file), + {"reason": "test", "watch_interval": 60}, + ) fake_http.post.assert_awaited_once() payload = fake_http.post.await_args.kwargs["json"] @@ -564,7 +632,7 @@ async def test_add_resource_forwards_processing_mode(): await client.add_resource( "https://example.com/demo.md", - processing_mode="vectors_only", + {"processing_mode": "vectors_only"}, ) fake_http.post.assert_awaited_once() @@ -583,8 +651,7 @@ async def test_add_resource_forwards_declared_add_type_with_exact_target(): await client.add_resource( "space:home", - add_type=" feishu ", - to="viking://resources/feishu", + {"add_type": " feishu ", "to": "viking://resources/feishu"}, ) payload = fake_http.post.await_args.kwargs["json"] @@ -598,7 +665,7 @@ async def test_add_resource_declared_add_type_requires_exact_target(): client = AsyncHTTPClient(url="http://127.0.0.1:1933") with pytest.raises(ValueError, match="exact 'to'"): - await client.add_resource("space:home", add_type="feishu") + await client.add_resource("space:home", {"add_type": "feishu"}) @pytest.mark.asyncio @@ -608,9 +675,11 @@ async def test_add_resource_declared_add_type_rejects_parent(): with pytest.raises(ValueError, match="'parent'"): await client.add_resource( "space:home", - add_type="feishu", - to="viking://resources/feishu", - parent="viking://resources/imports", + { + "add_type": "feishu", + "to": "viking://resources/feishu", + "parent": "viking://resources/imports", + }, ) @@ -629,8 +698,7 @@ async def test_add_resource_declared_add_type_skips_local_file_upload(tmp_path): await client.add_resource( str(source), - add_type="feishu", - to="viking://resources/feishu", + {"add_type": "feishu", "to": "viking://resources/feishu"}, ) client._upload_temp_file.assert_not_awaited() @@ -650,13 +718,14 @@ def test_sync_add_resource_accepts_and_forwards_declared_add_type(): ) as mock_add_resource: result = client.add_resource( "space:home", - add_type="feishu", - to="viking://resources/feishu", + {"add_type": "feishu", "to": "viking://resources/feishu"}, ) assert result["root_uri"] == "viking://resources/feishu" - assert mock_add_resource.await_args.kwargs["add_type"] == "feishu" - assert mock_add_resource.await_args.kwargs["to"] == "viking://resources/feishu" + assert mock_add_resource.await_args.args[1] == { + "add_type": "feishu", + "to": "viking://resources/feishu", + } @pytest.mark.asyncio @@ -675,42 +744,6 @@ async def test_add_resource_omits_default_processing_mode_for_legacy_servers(): assert "processing_mode" not in payload -@pytest.mark.asyncio -async def test_add_resource_preserves_positional_watch_args_for_legacy_callers(): - client = AsyncHTTPClient(url="http://127.0.0.1:1933") - fake_http = SimpleNamespace(post=AsyncMock(return_value=object())) - client._http = fake_http - client._handle_response_data = lambda _response: { - "result": {"root_uri": "viking://resources/demo"} - } - - await client.add_resource( - "https://example.com/demo.md", - None, - None, - "", - "", - False, - None, - False, - None, - None, - None, - True, - None, - 1440, - {"site": True}, - False, - ) - - fake_http.post.assert_awaited_once() - payload = fake_http.post.await_args.kwargs["json"] - assert payload["watch_interval"] == 1440 - assert payload["args"] == {"site": True} - assert payload["telemetry"] is False - assert "processing_mode" not in payload - - @pytest.mark.asyncio async def test_admin_create_paths_accept_initial_user_config(): client = AsyncHTTPClient(url="http://localhost:1933") @@ -799,22 +832,14 @@ async def test_add_resource_sends_tags_and_tag_mode(): } await client.add_resource( - path="https://example.com/demo.md", - tags=["team=search"], - tag_mode="append", + "https://example.com/demo.md", + {"tags": ["team=search"], "tag_mode": "append"}, ) fake_http.post.assert_awaited_once_with( "/api/v1/resources", json={ "path": "https://example.com/demo.md", - "reason": "", - "instruction": "", - "wait": False, - "strict": False, - "directly_upload_media": True, - "watch_interval": 0, - "telemetry": False, "tags": ["team=search"], "tag_mode": "append", }, @@ -829,15 +854,17 @@ async def test_find_uses_node_limit_as_http_limit_and_normalizes_target_uri_list client._handle_response_data = lambda _response: {"result": {"total": 0, "resources": []}} await client.find( - query="sample", - target_uri=["/resources/demo", "viking://resources/kept"], - limit=3, - node_limit=9, - score_threshold=0.4, - filter={"type": "resource"}, - context_type="resource", - tags=["k:v"], - telemetry={"enabled": True}, + "sample", + { + "target_uri": ["/resources/demo", "viking://resources/kept"], + "limit": 3, + "node_limit": 9, + "score_threshold": 0.4, + "filter": {"type": "resource"}, + "context_type": "resource", + "tags": ["k:v"], + "telemetry": {"enabled": True}, + }, ) fake_http.post.assert_awaited_once_with( @@ -845,7 +872,8 @@ async def test_find_uses_node_limit_as_http_limit_and_normalizes_target_uri_list json={ "query": "sample", "target_uri": ["viking://resources/demo", "viking://resources/kept"], - "limit": 9, + "limit": 3, + "node_limit": 9, "score_threshold": 0.4, "filter": {"type": "resource"}, "context_type": "resource", @@ -862,8 +890,14 @@ async def test_search_uses_session_wrapper_session_id_in_payload(): client._http = fake_http client._handle_response_data = lambda _response: {"result": {"total": 0, "resources": []}} - session = Session(client, "thread-123") - await client.search(query="sample", target_uri="/resources/demo", session=session, limit=5) + await client.search( + "sample", + { + "target_uri": "/resources/demo", + "session_id": "thread-123", + "limit": 5, + }, + ) fake_http.post.assert_awaited_once_with( "/api/v1/search/search", @@ -872,7 +906,6 @@ async def test_search_uses_session_wrapper_session_id_in_payload(): "target_uri": "viking://resources/demo", "session_id": "thread-123", "limit": 5, - "telemetry": False, }, ) @@ -918,7 +951,11 @@ async def test_glob_normalizes_scope_uri(): fake_http.post.assert_awaited_once_with( "/api/v1/search/glob", - json={"pattern": "**/*.md", "uri": "viking://resources/"}, + json={ + "pattern": "**/*.md", + "uri": "viking://resources/", + "node_limit": 256, + }, ) @@ -987,8 +1024,7 @@ async def test_batch_write_http_timeout_outlives_server_wait_timeout(): await client.batch_write( "viking://resources/wiki", [], - wait=True, - timeout=300.0, + {"wait": True, "timeout": 300.0}, ) request_timeout = client._request.await_args.kwargs["timeout"] @@ -1088,7 +1124,7 @@ async def test_session_wrapper_forwards_commit_context_and_archive_operations(): client.get_session_archive = AsyncMock(return_value={"archive_id": "arc-1"}) client.delete_session = AsyncMock(return_value=None) - commit_result = await session.commit(keep_recent_count=2) + commit_result = await session.commit({"keep_recent_count": 2}) context_result = await session.get_session_context(2048) archive_result = await session.get_archive("arc-1") await session.delete() @@ -1096,7 +1132,7 @@ async def test_session_wrapper_forwards_commit_context_and_archive_operations(): assert commit_result == {"status": "completed"} assert context_result == {"messages": []} assert archive_result == {"archive_id": "arc-1"} - client.commit_session.assert_awaited_once_with("thread-1", keep_recent_count=2) + client.commit_session.assert_awaited_once_with("thread-1", {"keep_recent_count": 2}) client.get_session_context.assert_awaited_once_with("thread-1", 2048) client.get_session_archive.assert_awaited_once_with("thread-1", "arc-1") client.delete_session.assert_awaited_once_with("thread-1") diff --git a/sdk/python/tests/test_options_api.py b/sdk/python/tests/test_options_api.py new file mode 100644 index 0000000000..731069a87a --- /dev/null +++ b/sdk/python/tests/test_options_api.py @@ -0,0 +1,605 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from openviking_sdk import ( + AsyncHTTPClient, + FindOptions, + SearchContextOptions, + SyncHTTPClient, + WriteOptions, +) + + +def _client() -> tuple[AsyncHTTPClient, AsyncMock]: + client = AsyncHTTPClient(url="http://localhost:1933") + request = AsyncMock(return_value=object()) + client._http = SimpleNamespace( + post=request, + put=request, + ) + client._handle_response_data = lambda _response: {"result": {"ok": True}} + return client, request + + +def test_options_are_public_typed_dicts(): + find: FindOptions = {"limit": 0, "tags": []} + context: SearchContextOptions = {"purpose": "coding", "max_tokens": 3000} + write: WriteOptions = {"processing_mode": "vectors_only", "wait": False} + + assert find["limit"] == 0 + assert context["purpose"] == "coding" + assert write["processing_mode"] == "vectors_only" + + +@pytest.mark.asyncio +async def test_find_serializes_options_and_preserves_explicit_empty_values(): + client, post = _client() + + await client.find( + "authentication", + { + "target_uri": ["/resources/docs", "viking://user/memories"], + "limit": 0, + "level": 0, + "tags": [], + "include_provenance": False, + "extra": {"future_flag": False}, + }, + ) + + post.assert_awaited_once_with( + "/api/v1/search/find", + json={ + "query": "authentication", + "target_uri": [ + "viking://resources/docs", + "viking://user/memories", + ], + "limit": 0, + "level": 0, + "tags": [], + "include_provenance": False, + "future_flag": False, + }, + ) + + +@pytest.mark.asyncio +async def test_search_context_sets_mode_and_context_fields(): + client, post = _client() + + await client.search_context( + "continue refactor", + { + "session_id": "session-1", + "purpose": "coding", + "max_tokens": 3000, + "dedup_turns": 5, + "rewrite": "auto", + }, + ) + + post.assert_awaited_once_with( + "/api/v1/search/search", + json={ + "query": "continue refactor", + "mode": "context", + "session_id": "session-1", + "purpose": "coding", + "max_tokens": 3000, + "dedup_turns": 5, + "rewrite": "auto", + }, + ) + + +@pytest.mark.asyncio +async def test_extra_cannot_override_official_or_fixed_fields(): + client, _post = _client() + + with pytest.raises(ValueError, match="limit"): + await client.find("query", {"limit": 5, "extra": {"limit": 10}}) + + with pytest.raises(ValueError, match="mode"): + await client.reindex( + "viking://resources", + {"extra": {"mode": "prune_orphans"}}, + ) + + with pytest.raises(ValueError, match="tags"): + await client.reindex( + "viking://resources", + {"extra": {"tags": ["team=search"]}}, + ) + + with pytest.raises(ValueError, match="mode"): + await client.search_context("query", {"extra": {"mode": "list"}}) + + with pytest.raises(ValueError, match="extraction_metadata"): + await client.commit_session( + "session-1", + { + "event_tags": ["team=search"], + "extra": {"extraction_metadata": {}}, + }, + ) + + +@pytest.mark.asyncio +async def test_add_message_prefers_parts_when_content_is_also_provided(): + client, post = _client() + + await client.add_message( + "session-1", + { + "role": "assistant", + "content": "fallback text", + "parts": [{"type": "text", "text": "structured text"}], + }, + ) + + post.assert_awaited_once_with( + "/api/v1/sessions/session-1/messages", + json={ + "role": "assistant", + "parts": [{"type": "text", "text": "structured text"}], + }, + ) + + +@pytest.mark.asyncio +async def test_add_message_keeps_content_when_parts_is_null(): + client, post = _client() + + await client.add_message( + "session-1", + { + "role": "assistant", + "content": "fallback text", + "parts": None, + }, + ) + + post.assert_awaited_once_with( + "/api/v1/sessions/session-1/messages", + json={ + "role": "assistant", + "content": "fallback text", + }, + ) + + +@pytest.mark.asyncio +async def test_add_message_uses_content_when_parts_is_empty(): + client, post = _client() + + await client.add_message( + "session-1", + { + "role": "assistant", + "content": "fallback text", + "parts": [], + }, + ) + + post.assert_awaited_once_with( + "/api/v1/sessions/session-1/messages", + json={ + "role": "assistant", + "content": "fallback text", + }, + ) + + +@pytest.mark.asyncio +async def test_add_message_rejects_empty_parts_without_content(): + client, post = _client() + + with pytest.raises(ValueError, match="Either content or non-empty parts"): + await client.add_message( + "session-1", + { + "role": "assistant", + "parts": [], + }, + ) + + post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_batch_add_messages_normalizes_empty_and_nonempty_parts(): + client, post = _client() + + await client.batch_add_messages( + "session-1", + [ + { + "role": "user", + "content": "keep content", + "parts": [], + }, + { + "role": "assistant", + "content": "drop content", + "parts": [{"type": "text", "text": "structured"}], + }, + ], + ) + + post.assert_awaited_once_with( + "/api/v1/sessions/session-1/messages/batch", + json={ + "messages": [ + { + "role": "user", + "content": "keep content", + }, + { + "role": "assistant", + "parts": [{"type": "text", "text": "structured"}], + }, + ], + }, + ) + + +@pytest.mark.asyncio +async def test_batch_add_messages_rejects_empty_parts_without_content(): + client, post = _client() + + with pytest.raises(ValueError, match="Either content or non-empty parts"): + await client.batch_add_messages( + "session-1", + [{"role": "user", "parts": []}], + ) + + post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_write_uses_options_and_extra(): + client, post = _client() + + await client.write( + "/resources/note.md", + "", + { + "mode": "replace", + "wait": False, + "processing_mode": "vectors_only", + "extra": {"future_write_flag": 0}, + }, + ) + + post.assert_awaited_once_with( + "/api/v1/content/write", + json={ + "uri": "viking://resources/note.md", + "content": "", + "mode": "replace", + "wait": False, + "processing_mode": "vectors_only", + "future_write_flag": 0, + }, + ) + + +@pytest.mark.asyncio +async def test_session_message_and_commit_options_use_latest_fields(): + client, post = _client() + + await client.add_message( + "session-1", + { + "role": "assistant", + "content": "done", + "turn_id": "turn-1", + "message_kind": "assistant_step", + "source_message_ids": ["user-1"], + }, + ) + await client.commit_session( + "session-1", + { + "retention_mode": "turn_budget", + "keep_recent_turn_count": 3, + "retained_message_token_budget": 12_000, + "min_raw_tail_steps": 1, + }, + ) + + assert post.await_args_list[0].kwargs["json"] == { + "role": "assistant", + "content": "done", + "turn_id": "turn-1", + "message_kind": "assistant_step", + "source_message_ids": ["user-1"], + } + assert post.await_args_list[1].kwargs["json"] == { + "retention_mode": "turn_budget", + "keep_recent_turn_count": 3, + "retained_message_token_budget": 12_000, + "min_raw_tail_steps": 1, + } + + +@pytest.mark.asyncio +async def test_turn_retention_fields_require_turn_budget_mode(): + client, _post = _client() + + with pytest.raises(ValueError, match="retention_mode"): + await client.commit_session( + "session-1", + {"keep_recent_turn_count": 3}, + ) + + +@pytest.mark.asyncio +async def test_add_resource_uses_options_and_normalizes_request_fields(): + client, post = _client() + + await client.add_resource( + "https://example.com/manual.pdf", + { + "to": "/resources/manual.pdf", + "create_parent": False, + "wait": False, + "processing_mode": "vectors_only", + "tags": [], + "tag_mode": "replace", + "extra": {"future_ingest_flag": False}, + }, + ) + + post.assert_awaited_once_with( + "/api/v1/resources", + json={ + "path": "https://example.com/manual.pdf", + "to": "viking://resources/manual.pdf", + "create_parent": False, + "wait": False, + "processing_mode": "vectors_only", + "tags": [], + "tag_mode": "replace", + "future_ingest_flag": False, + }, + ) + + +@pytest.mark.asyncio +async def test_skill_writes_use_options_and_extra(tmp_path): + client, post = _client() + skill_file = tmp_path / "SKILL.md" + skill_file.write_text("# Demo") + client._upload_temp_file = AsyncMock(return_value="skill-upload") + + await client.add_skill( + str(skill_file), + {"wait": False, "target_uri": "/user/skills", "extra": {"future": 0}}, + ) + await client.update_skill( + "demo", + {"name": "demo"}, + {"source_metadata": {}, "target_uri": "/agent/skills"}, + ) + + assert post.await_args_list[0].kwargs["json"] == { + "temp_file_id": "skill-upload", + "wait": False, + "target_uri": "viking://user/skills", + "future": 0, + } + assert post.await_args_list[1].kwargs["json"] == { + "data": {"name": "demo"}, + "source_metadata": {}, + "target_uri": "viking://agent/skills", + } + + +@pytest.mark.asyncio +async def test_batch_messages_use_options_and_preserve_message_fields(): + client, post = _client() + messages = [ + { + "role": "assistant", + "parts": [{"type": "text", "text": "done"}], + "turn_id": "turn-1", + "message_kind": "assistant_step", + } + ] + + await client.batch_add_messages( + "session-1", + messages, + {"telemetry": False, "extra": {"future_batch_flag": 0}}, + ) + + post.assert_awaited_once_with( + "/api/v1/sessions/session-1/messages/batch", + json={ + "messages": messages, + "telemetry": False, + "future_batch_flag": 0, + }, + ) + + +@pytest.mark.asyncio +async def test_unknown_official_option_suggests_extra(): + client, _post = _client() + + with pytest.raises(TypeError, match="use 'extra'"): + await client.find("query", {"future_field": True}) # type: ignore[typeddict-unknown-key] + + +@pytest.mark.asyncio +async def test_legacy_keyword_options_are_merged_into_find_payload(): + client, post = _client() + + await client.find( + "query", + {"target_uri": "/resources", "limit": 0}, + level=2, + tags=[], + ) + + post.assert_awaited_once_with( + "/api/v1/search/find", + json={ + "query": "query", + "target_uri": "viking://resources", + "limit": 0, + "level": 2, + "tags": [], + }, + ) + + +@pytest.mark.asyncio +async def test_legacy_keyword_options_reject_unknown_and_duplicate_fields(): + client, post = _client() + + with pytest.raises(TypeError, match=r"unsupported option 'limti'.*options\[\"extra\"\]"): + await client.find("query", limti=5) + + with pytest.raises(ValueError, match=r"'limit'.*both options and kwargs"): + await client.find("query", {"limit": 5}, limit=10) + + post.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_legacy_keyword_options_preserve_session_null_and_message_fields(): + client, post = _client() + + await client.create_session(auto_commit_policy=None, session_id="session-1") + await client.add_message( + "session-1", + role="assistant", + content="fallback text", + parts=[], + turn_id="turn-1", + ) + + assert post.await_args_list[0].kwargs["json"] == { + "auto_commit_policy": None, + "session_id": "session-1", + } + assert post.await_args_list[1].kwargs["json"] == { + "role": "assistant", + "content": "fallback text", + "turn_id": "turn-1", + } + + +def test_sync_client_forwards_legacy_keyword_options(): + client = SyncHTTPClient(url="http://localhost:1933") + client._async_client.find = AsyncMock(return_value={"total": 1}) + + assert client.find("query", limit=0, level=2) == {"total": 1} + + client._async_client.find.assert_awaited_once_with( + "query", + None, + limit=0, + level=2, + ) + + +def test_sync_client_forwards_options_without_rebuilding_payload(): + client = SyncHTTPClient(url="http://localhost:1933") + client._async_client.find = AsyncMock(return_value={"total": 1}) + client._async_client.search_context = AsyncMock(return_value={"rendered": "ctx"}) + client._async_client.write = AsyncMock(return_value={"uri": "viking://resources/a.md"}) + + assert client.find("query", {"limit": 0}) == {"total": 1} + assert client.search_context("query", {"max_tokens": 64}) == {"rendered": "ctx"} + assert client.write("/resources/a.md", "", {"wait": False}) == { + "uri": "viking://resources/a.md" + } + + client._async_client.find.assert_awaited_once_with("query", {"limit": 0}) + client._async_client.search_context.assert_awaited_once_with("query", {"max_tokens": 64}) + client._async_client.write.assert_awaited_once_with("/resources/a.md", "", {"wait": False}) + + +@pytest.mark.asyncio +async def test_agent_evolution_queries_normalize_experience_uri(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response = lambda _response: {"experience_uri": "ok"} + + await client.list_experience_trajectories( + "/user/memories/experiences/a.md", + { + "limit": 25, + "offset": 50, + "start_date": "2026-08-01", + "end_date": "2026-08-10", + }, + ) + await client.get_experience_outcomes( + "/user/memories/experiences/a.md", + {"start_date": "2026-08-01", "end_date": "2026-08-10"}, + ) + + assert client._request.await_args_list[0].args == ( + "GET", + "/api/v1/agent-evolution/experiences/trajectories", + ) + assert client._request.await_args_list[0].kwargs["params"] == { + "experience_uri": "viking://user/memories/experiences/a.md", + "limit": 25, + "offset": 50, + "start_date": "2026-08-01", + "end_date": "2026-08-10", + } + assert client._request.await_args_list[1].kwargs["params"] == { + "experience_uri": "viking://user/memories/experiences/a.md", + "start_date": "2026-08-01", + "end_date": "2026-08-10", + } + + +@pytest.mark.asyncio +async def test_openviking_assets_resolve_and_preflight_latest_fields(): + client = AsyncHTTPClient(url="http://localhost:1933") + client._request = AsyncMock(return_value=object()) + client._handle_response = lambda _response: {"ok": True} + + await client.resolve_openviking_assets( + "protocol: openviking-assets/1", + { + "manifest_label": "custom.yaml", + "extra": {"future_flag": False}, + }, + ) + await client.preflight_openviking_asset( + "private-repo", + "https://github.com/example/private.git", + { + "branch": "main", + "commit": "0123456789abcdef", + "auth_config": {"username": "oauth2", "token": "secret"}, + }, + ) + + assert client._request.await_args_list[0].args == ( + "POST", + "/api/v1/openviking-assets/resolve", + ) + assert client._request.await_args_list[0].kwargs["json"] == { + "manifest_yaml": "protocol: openviking-assets/1", + "manifest_label": "custom.yaml", + "future_flag": False, + } + assert client._request.await_args_list[1].kwargs["json"] == { + "name": "private-repo", + "connector": "git", + "repo_url": "https://github.com/example/private.git", + "branch": "main", + "commit": "0123456789abcdef", + "auth_config": {"username": "oauth2", "token": "secret"}, + } diff --git a/sdk/python/tests/test_repository_options_migration.py b/sdk/python/tests/test_repository_options_migration.py new file mode 100644 index 0000000000..21a4805eec --- /dev/null +++ b/sdk/python/tests/test_repository_options_migration.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +MIGRATED_BENCHMARKS = [ + REPOSITORY_ROOT / "benchmark/RAG/src/core/vector_store.py", + REPOSITORY_ROOT / "benchmark/retrieval/grep/vikingdb_bm25/performance/step1_add_resource.py", + REPOSITORY_ROOT / "benchmark/retrieval/grep/vikingdb_bm25/effectiveness/step1_add_resource.py", +] +OPTIONS_METHODS = {"add_resource", "find"} +LEGACY_OPTION_NAMES = { + "parent", + "processing_mode", + "reason", + "target_uri", + "telemetry", + "wait", +} + + +@pytest.mark.parametrize("source_path", MIGRATED_BENCHMARKS) +def test_benchmark_sdk_calls_use_options_dict(source_path: Path): + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + violations = [] + + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in OPTIONS_METHODS: + continue + legacy_keywords = sorted( + keyword.arg + for keyword in node.keywords + if keyword.arg is not None and keyword.arg in LEGACY_OPTION_NAMES + ) + if legacy_keywords: + violations.append((node.lineno, node.func.attr, legacy_keywords)) + + assert not violations, f"{source_path}: legacy SDK option keywords: {violations}" diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index cdc4e45d9e..6fb25726ac 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -51,8 +51,11 @@ await client.updateSessionConfig("s1", { }, }); await client.updateSessionConfig("s1", { autoCommitPolicy: null }); -await client.commitSession("s1", 0, undefined, ["team=search", "channel=web"]); -await client.commitSession("s1", 0, undefined, []); +await client.commitSession("s1", { + keepRecentCount: 0, + eventTags: ["team=search", "channel=web"], +}); +await client.commitSession("s1", { keepRecentCount: 0, eventTags: [] }); ``` Deployments using shared temporary storage can set `uploadMode: "shared"`; the server also accepts `"local"` (the default). diff --git a/sdk/typescript/README_CN.md b/sdk/typescript/README_CN.md index 86fd3211d9..17d0e3beff 100644 --- a/sdk/typescript/README_CN.md +++ b/sdk/typescript/README_CN.md @@ -51,8 +51,11 @@ await client.updateSessionConfig("s1", { }, }); await client.updateSessionConfig("s1", { autoCommitPolicy: null }); -await client.commitSession("s1", 0, undefined, ["team=search", "channel=web"]); -await client.commitSession("s1", 0, undefined, []); +await client.commitSession("s1", { + keepRecentCount: 0, + eventTags: ["team=search", "channel=web"], +}); +await client.commitSession("s1", { keepRecentCount: 0, eventTags: [] }); ``` 使用共享临时存储的部署可设置 `uploadMode: "shared"`;服务端也接受 `"local"`(默认值)。 diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 0a6c481f49..a614b2bef7 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -8,8 +8,15 @@ import { import { OpenVikingTransport, type TransportOptions } from "./transport.js"; import type { AddResourceOptions, + BatchAddMessagesOptions, + BatchWriteOperation, + BatchWriteOptions, ClientConfig, + CommitSessionOptions, CreateSessionOptions, + ExperienceOutcomeOptions, + ExperienceTrajectoryOptions, + FindOptions, FindResult, GitBlob, GitCommitOptions, @@ -20,8 +27,14 @@ import type { GrepOptions, ImportPackOptions, Message, + PreflightAssetOptions, + ReindexOptions, RequestOptions, + ResolveAssetsOptions, + SearchContextOptions, + SearchContextResult, SearchOptions, + SetTagsOptions, TaskListOptions, TreeOptions, UpdateSessionConfigOptions, @@ -36,6 +49,19 @@ const compact = (value: JsonObject): JsonObject => ([, item]) => item !== undefined && item !== null, ), ); +const mergeExtra = ( + body: JsonObject, + extra: JsonObject | undefined, + protectedKeys: readonly string[] = [], +): JsonObject => { + const protectedFields = new Set(protectedKeys); + for (const [key, value] of Object.entries(extra ?? {})) { + if (key in body || protectedFields.has(key)) + throw new TypeError(`OpenViking: extra cannot override ${key}`); + if (value !== undefined && value !== null) body[key] = value; + } + return body; +}; const pathPart = (value: string): string => encodeURIComponent(value); /** Normalize a short OpenViking URI to the canonical `viking://` form. */ @@ -110,17 +136,18 @@ export class OpenVikingClient { const body: JsonObject = compact({ to: options.to, parent: options.parent, + create_parent: options.createParent, reason: options.reason, instruction: options.instruction, - wait: options.wait ?? false, + wait: options.wait, timeout: options.timeout, - strict: options.strict ?? false, + strict: options.strict, ignore_dirs: options.ignoreDirs, include: options.include, exclude: options.exclude, - directly_upload_media: options.directlyUploadMedia ?? true, + directly_upload_media: options.directlyUploadMedia, preserve_structure: options.preserveStructure, - watch_interval: options.watchInterval ?? 0, + watch_interval: options.watchInterval, processing_mode: options.processingMode, args: options.args && Object.keys(options.args).length @@ -135,13 +162,15 @@ export class OpenVikingClient { body.temp_file_id = await this.upload(local.blob, local.filename); body.source_name = local.sourceName; } else body.path = source; - return this.request("POST", "/api/v1/resources", { body }); + return this.request("POST", "/api/v1/resources", { + body: mergeExtra(body, options.extra), + }); } /** Install a skill from inline data or an existing Node.js path. */ async addSkill( source: unknown, - options: WaitOptions & { targetUri?: string } = {}, + options: WaitOptions & { targetUri?: string; extra?: JsonObject } = {}, ): Promise { const body: JsonObject = compact({ wait: options.wait ?? false, @@ -154,7 +183,9 @@ export class OpenVikingClient { if (local) body.temp_file_id = await this.upload(local.blob, local.filename); else body.data = source; - return this.request("POST", "/api/v1/skills", { body }); + return this.request("POST", "/api/v1/skills", { + body: mergeExtra(body, options.extra), + }); } /** List installed skills. */ listSkills( @@ -228,6 +259,7 @@ export class OpenVikingClient { options: WaitOptions & { sourceMetadata?: JsonObject; targetUri?: string; + extra?: JsonObject; } = {}, ): Promise { const body: JsonObject = compact({ @@ -242,7 +274,9 @@ export class OpenVikingClient { if (local) body.temp_file_id = await this.upload(local.blob, local.filename); else body.data = source; - return this.request("PUT", `/api/v1/skills/${pathPart(name)}`, { body }); + return this.request("PUT", `/api/v1/skills/${pathPart(name)}`, { + body: mergeExtra(body, options.extra), + }); } /** Delete an installed skill. */ deleteSkill(name: string, targetUri?: string): Promise { @@ -317,7 +351,7 @@ export class OpenVikingClient { } /** Find relevant content without session context. */ - async find(query: string, options: SearchOptions = {}): Promise { + async find(query: string, options: FindOptions = {}): Promise { return this.searchRequest("find", query, options); } /** Search relevant content with optional session context. */ @@ -330,29 +364,73 @@ export class OpenVikingClient { private async searchRequest( kind: "find" | "search", query: string, - options: SearchOptions, + options: FindOptions | SearchOptions, ): Promise { let imageUrl: string | undefined; if (typeof options.image === "string") { imageUrl = (await nodeImagePathToDataURI(options.image)) ?? options.image; } + const body = compact({ + query, + target_uri: options.targetUri, + image_url: imageUrl, + session_id: + kind === "search" ? (options as SearchOptions).sessionId : undefined, + limit: options.limit, + node_limit: options.nodeLimit, + score_threshold: options.scoreThreshold, + filter: options.filter, + context_type: options.contextType, + telemetry: options.telemetry, + since: options.since, + until: options.until, + time_field: options.timeField, + level: options.level, + tags: options.tags, + include_provenance: options.includeProvenance, + }); return this.request("POST", `/api/v1/search/${kind}`, { - body: compact({ - query, - target_uri: options.targetUri ?? "", - image_url: imageUrl, - session_id: kind === "search" ? options.sessionId : undefined, - limit: options.nodeLimit ?? options.limit ?? 10, - score_threshold: options.scoreThreshold, - filter: options.filter, - context_type: options.contextType, - telemetry: options.telemetry, - since: options.since, - until: options.until, - time_field: options.timeField, - level: options.level, - tags: options.tags, - }), + body: mergeExtra(body, options.extra), + }); + } + /** Assemble injection-ready context on the server. */ + async searchContext( + query: string, + options: SearchContextOptions = {}, + ): Promise { + let imageUrl: string | undefined; + if (typeof options.image === "string") + imageUrl = (await nodeImagePathToDataURI(options.image)) ?? options.image; + const body = compact({ + query, + mode: "context", + image_url: imageUrl, + session_id: options.sessionId, + limit: options.limit, + node_limit: options.nodeLimit, + score_threshold: options.scoreThreshold, + filter: options.filter, + context_type: options.contextType, + include_provenance: options.includeProvenance, + tags: options.tags, + since: options.since, + until: options.until, + time_field: options.timeField, + query_expansion: options.queryExpansion, + max_tokens: options.maxTokens, + quotas: options.quotas, + purpose: options.purpose, + detail: options.detail, + dedup_turns: options.dedupTurns, + exclude_uris: options.excludeUris, + peer_scope: options.peerScope, + other_peer_penalty: options.otherPeerPenalty, + rewrite: options.rewrite, + rewrite_max_bullets: options.rewriteMaxBullets, + telemetry: options.telemetry, + }); + return this.request("POST", "/api/v1/search/search", { + body: mergeExtra(body, options.extra), }); } /** Search file contents by pattern. */ @@ -488,6 +566,22 @@ export class OpenVikingClient { query: { uri: normalizeURI(uri), offset, limit }, }); } + /** Download raw stored bytes. */ + downloadBytes(uri: string): Promise { + return this.transport.consume( + "GET", + "/api/v1/content/download", + { query: { uri: normalizeURI(uri) } }, + async (response) => { + if ( + !response.ok || + response.headers.get("content-type")?.includes("json") + ) + return this.transport.parseResponse(response); + return new Uint8Array(await response.arrayBuffer()); + }, + ); + } /** Read L0 abstract content. */ abstract(uri: string): Promise { return this.request("GET", "/api/v1/content/abstract", { @@ -506,54 +600,82 @@ export class OpenVikingClient { content: string, options: WriteOptions = {}, ): Promise { + const body = compact({ + uri: normalizeURI(uri), + content, + mode: options.mode, + processing_mode: options.processingMode, + wait: options.wait, + timeout: options.timeout, + telemetry: options.telemetry, + }); return this.request("POST", "/api/v1/content/write", { - body: compact({ - uri: normalizeURI(uri), - content, - mode: options.mode ?? "replace", - processing_mode: options.processingMode, - wait: options.wait ?? false, - timeout: options.timeout, - telemetry: options.telemetry, - }), + body: mergeExtra(body, options.extra), + }); + } + /** Apply preconditioned file writes in one request. */ + batchWrite( + rootUri: string, + operations: BatchWriteOperation[], + options: BatchWriteOptions = {}, + ): Promise { + const body = compact({ + root_uri: normalizeURI(rootUri), + operations: operations.map((operation) => + compact({ + uri: normalizeURI(operation.uri), + content: operation.content, + content_base64: operation.contentBase64, + precondition: compact({ + kind: operation.precondition.kind, + base_hash: operation.precondition.baseHash, + }), + }), + ), + wait: options.wait, + timeout: options.timeout, + telemetry: options.telemetry, + }); + return this.request("POST", "/api/v1/content/batch-write", { + body: mergeExtra(body, options.extra), }); } /** Set retrieval tags. */ setTags( uri: string, tags: string[], - options: { mode?: string; recursive?: boolean; telemetry?: unknown } = {}, + options: SetTagsOptions = {}, ): Promise { + const body = compact({ + uri: normalizeURI(uri), + tags, + mode: options.mode ?? "replace", + recursive: options.recursive ?? false, + telemetry: options.telemetry, + }); return this.request("POST", "/api/v1/fs/attrs/set_tags", { - body: compact({ - uri: normalizeURI(uri), - tags, - mode: options.mode ?? "replace", - recursive: options.recursive ?? false, - telemetry: options.telemetry, - }), + body: mergeExtra(body, options.extra, [ + "uri", + "tags", + "mode", + "recursive", + "telemetry", + ]), }); } /** Rebuild indexes for a URI. */ - reindex( - uri: string, - options: { - mode?: string; - wait?: boolean; - dryRun?: boolean; - tags?: string[]; - tagMode?: "replace" | "append"; - } = {}, - ): Promise { + reindex(uri: string, options: ReindexOptions = {}): Promise { + const body = compact({ + uri: normalizeURI(uri), + mode: options.mode ?? "vectors_only", + wait: options.wait ?? true, + dry_run: options.dryRun ?? false, + tags: options.tags, + tag_mode: + options.tags === undefined ? undefined : (options.tagMode ?? "replace"), + }); return this.request("POST", "/api/v1/content/reindex", { - body: compact({ - uri: normalizeURI(uri), - mode: options.mode ?? "vectors_only", - wait: options.wait ?? true, - dry_run: options.dryRun ?? false, - tags: options.tags, - tag_mode: options.tags === undefined ? undefined : options.tagMode ?? "replace", - }), + body: mergeExtra(body, options.extra, ["tags", "tag_mode"]), }); } @@ -568,7 +690,7 @@ export class OpenVikingClient { if ("autoCommitPolicy" in options) body.auto_commit_policy = options.autoCommitPolicy ?? null; return this.request("POST", "/api/v1/sessions", { - body, + body: mergeExtra(body, options.extra), }); } /** List sessions visible to the caller. */ @@ -596,7 +718,7 @@ export class OpenVikingClient { "PATCH", `/api/v1/sessions/${pathPart(sessionId)}/config`, { - body, + body: mergeExtra(body, options.extra), }, ); } @@ -649,6 +771,9 @@ export class OpenVikingClient { parts: message.parts?.length ? message.parts : undefined, created_at: message.createdAt, peer_id: message.peerId, + turn_id: message.turnId, + message_kind: message.messageKind, + source_message_ids: message.sourceMessageIds, telemetry: message.telemetry, }), }, @@ -658,51 +783,60 @@ export class OpenVikingClient { batchAddMessages( sessionId: string, messages: Message[], - telemetry?: unknown, + options: BatchAddMessagesOptions = {}, ): Promise { return this.request( "POST", `/api/v1/sessions/${pathPart(sessionId)}/messages/batch`, { - body: compact({ - messages: messages.map((message) => { - if (message.content === undefined && !message.parts?.length) { - throw new TypeError( - "OpenViking: each message requires content or parts", - ); - } - const parts = message.parts?.length ? message.parts : undefined; - return compact({ - role: message.role, - content: parts ? undefined : message.content, - parts, - created_at: message.createdAt, - peer_id: message.peerId, - }); + body: mergeExtra( + compact({ + messages: messages.map((message) => { + if (message.content === undefined && !message.parts?.length) { + throw new TypeError( + "OpenViking: each message requires content or parts", + ); + } + const parts = message.parts?.length ? message.parts : undefined; + return compact({ + role: message.role, + content: parts ? undefined : message.content, + parts, + created_at: message.createdAt, + peer_id: message.peerId, + turn_id: message.turnId, + message_kind: message.messageKind, + source_message_ids: message.sourceMessageIds, + }); + }), + telemetry: options.telemetry, }), - telemetry, - }), + options.extra, + ), }, ); } /** Commit a session and extract memories. */ commitSession( sessionId: string, - keepRecentCount = 0, - telemetry?: unknown, - eventTags?: string[], + options: CommitSessionOptions = {}, ): Promise { + const body = compact({ + keep_recent_count: options.keepRecentCount, + retention_mode: options.retentionMode, + keep_recent_turn_count: options.keepRecentTurnCount, + retained_message_token_budget: options.retainedMessageTokenBudget, + min_raw_tail_steps: options.minRawTailSteps, + extraction_metadata: + options.eventTags === undefined + ? undefined + : { event: { tags: options.eventTags } }, + telemetry: options.telemetry, + }); return this.request( "POST", `/api/v1/sessions/${pathPart(sessionId)}/commit`, - { - body: compact({ - keep_recent_count: keepRecentCount, - telemetry, - extraction_metadata: - eventTags === undefined ? undefined : { event: { tags: eventTags } }, - }), - }, + { body: mergeExtra(body, options.extra) }, ); } /** Export a resource subtree to a local OVPack file. */ @@ -1033,4 +1167,97 @@ export class OpenVikingClient { body: { action: cleanup ? "cleanup" : "migrate" }, }); } + /** Return the effective Agent Evolution switch for the caller's account. */ + adminGetAgentEvolution(): Promise { + return this.request("GET", "/api/v1/admin/agent-evolution"); + } + /** Persist and hot-reload Agent Evolution for the caller's account. */ + adminSetAgentEvolution(enabled: boolean): Promise { + return this.request("PUT", "/api/v1/admin/agent-evolution", { + body: { enabled }, + }); + } + /** Return effective and explicitly overridden settings for one account. */ + adminGetAccountSettings(accountId: string): Promise { + return this.request( + "GET", + `/api/v1/admin/accounts/${pathPart(accountId)}/settings`, + ); + } + /** Update the allowlisted Agent Evolution setting for one account. */ + adminSetAccountAgentEvolution( + accountId: string, + enabled: boolean, + ): Promise { + return this.request( + "PATCH", + `/api/v1/admin/accounts/${pathPart(accountId)}/settings`, + { body: { agent_evolution: { enabled } } }, + ); + } + /** List trajectories that consumed an Experience. */ + listExperienceTrajectories( + experienceUri: string, + options: ExperienceTrajectoryOptions = {}, + ): Promise { + return this.request( + "GET", + "/api/v1/agent-evolution/experiences/trajectories", + { + query: { + experience_uri: normalizeURI(experienceUri), + limit: options.limit, + offset: options.offset, + start_date: options.startDate, + end_date: options.endDate, + }, + }, + ); + } + /** Return the outcome distribution for an Experience. */ + getExperienceOutcomes( + experienceUri: string, + options: ExperienceOutcomeOptions = {}, + ): Promise { + return this.request("GET", "/api/v1/agent-evolution/experiences/outcomes", { + query: { + experience_uri: normalizeURI(experienceUri), + start_date: options.startDate, + end_date: options.endDate, + }, + }); + } + /** Parse and validate an OpenViking Assets manifest. */ + resolveOpenVikingAssets( + manifestYaml: string, + options: ResolveAssetsOptions = {}, + ): Promise { + const body = compact({ + manifest_yaml: manifestYaml, + catalog_yaml: options.catalogYaml, + manifest_label: options.manifestLabel, + catalog_label: options.catalogLabel, + }); + return this.request("POST", "/api/v1/openviking-assets/resolve", { + body: mergeExtra(body, options.extra), + }); + } + /** Verify read access to one Git asset. */ + preflightOpenVikingAsset( + name: string, + repoUrl: string, + options: PreflightAssetOptions = {}, + ): Promise { + const body = compact({ + name, + connector: "git", + repo_url: repoUrl, + branch: options.branch, + commit: options.commit, + auth_config: options.authConfig, + }); + return this.request("POST", "/api/v1/openviking-assets/preflight", { + body: mergeExtra(body, options.extra), + }); + } } diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 3d01d74142..6002fda4bc 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -76,6 +76,7 @@ export interface WaitOptions { export interface AddResourceOptions extends WaitOptions { to?: string; parent?: string; + createParent?: boolean; reason?: string; instruction?: string; strict?: boolean; @@ -89,17 +90,50 @@ export interface AddResourceOptions extends WaitOptions { args?: JsonObject; tags?: string[]; tagMode?: "replace" | "append"; + extra?: JsonObject; } /** Content write options. */ export interface WriteOptions extends WaitOptions { mode?: string; processingMode?: ProcessingMode; + extra?: JsonObject; } -/** Semantic retrieval options. */ -export interface SearchOptions { +/** One batch-write precondition. */ +export interface BatchWritePrecondition { + kind: "create_if_absent" | "replace_if_hash"; + baseHash?: string; +} +/** One preconditioned batch-write operation. */ +export interface BatchWriteOperation { + uri: string; + content?: string; + contentBase64?: string; + precondition: BatchWritePrecondition; +} +/** Batch-write request options. */ +export interface BatchWriteOptions extends WaitOptions { + extra?: JsonObject; +} +/** Retrieval tag update options. */ +export interface SetTagsOptions { + mode?: "replace" | "append"; + recursive?: boolean; + telemetry?: unknown; + extra?: JsonObject; +} +/** Reindex request options. */ +export interface ReindexOptions { + mode?: string; + wait?: boolean; + dryRun?: boolean; + tags?: string[]; + tagMode?: "replace" | "append"; + extra?: JsonObject; +} +/** Semantic retrieval options shared by find and search. */ +export interface FindOptions { targetUri?: TargetURI; image?: string; - sessionId?: string; limit?: number; nodeLimit?: number; scoreThreshold?: number; @@ -111,6 +145,40 @@ export interface SearchOptions { timeField?: string; level?: number[]; tags?: string[]; + includeProvenance?: boolean; + extra?: JsonObject; +} +/** Session-aware semantic retrieval options. */ +export interface SearchOptions extends FindOptions { + sessionId?: string; +} +/** Server-side context assembly options. */ +export interface SearchContextOptions { + image?: string; + sessionId?: string; + limit?: number; + nodeLimit?: number; + scoreThreshold?: number; + filter?: JsonObject; + contextType?: unknown; + includeProvenance?: boolean; + tags?: string[]; + since?: string; + until?: string; + timeField?: string; + queryExpansion?: "off" | "auto"; + maxTokens?: number; + quotas?: Record; + purpose?: "chat" | "coding"; + detail?: string | Record; + dedupTurns?: number; + excludeUris?: string[]; + peerScope?: "actor" | "all"; + otherPeerPenalty?: number | Record; + rewrite?: boolean | "auto"; + rewriteMaxBullets?: number; + telemetry?: unknown; + extra?: JsonObject; } /** Content grep options. */ export interface GrepOptions { @@ -144,6 +212,10 @@ export interface Message { parts?: JsonObject[]; createdAt?: string; peerId?: string; + turnId?: string; + messageKind?: + "user_query" | "assistant_step" | "tool_transport" | "checkpoint"; + sourceMessageIds?: string[]; telemetry?: unknown; } /** Session creation options. */ @@ -153,6 +225,7 @@ export interface CreateSessionOptions { autoCommitPolicy?: JsonObject | null; memoryExtractionConfig?: MemoryExtractionConfig; telemetry?: unknown; + extra?: JsonObject; } /** Event-memory extraction settings shared by session create and update. */ export interface MemoryExtractionConfig { @@ -165,6 +238,54 @@ export interface UpdateSessionConfigOptions { memoryExtractionConfig?: MemoryExtractionConfig; autoCommitPolicy?: JsonObject | null; telemetry?: unknown; + extra?: JsonObject; +} +/** Batch message request options. */ +export interface BatchAddMessagesOptions { + telemetry?: unknown; + extra?: JsonObject; +} +/** Session commit and turn-retention options. */ +export interface CommitSessionOptions { + keepRecentCount?: number; + retentionMode?: "turn_budget"; + keepRecentTurnCount?: number; + retainedMessageTokenBudget?: number; + minRawTailSteps?: number; + eventTags?: string[]; + telemetry?: unknown; + extra?: JsonObject; +} +/** Pagination options for Experience trajectories. */ +export interface ExperienceTrajectoryOptions { + limit?: number; + offset?: number; + startDate?: string; + endDate?: string; +} +/** Date filters for Experience outcome aggregation. */ +export interface ExperienceOutcomeOptions { + startDate?: string; + endDate?: string; +} +/** Manifest resolver options. */ +export interface ResolveAssetsOptions { + catalogYaml?: string; + manifestLabel?: string; + catalogLabel?: string; + extra?: JsonObject; +} +/** One-shot Git credentials for asset preflight. */ +export interface AssetGitAuth { + username?: string; + token?: string; +} +/** Git asset preflight options. */ +export interface PreflightAssetOptions { + branch?: string; + commit?: string; + authConfig?: AssetGitAuth; + extra?: JsonObject; } /** Background task filters. */ export interface TaskListOptions { @@ -188,13 +309,40 @@ export interface UpdateWatchOptions { reason?: string; instruction?: string; } +/** One retrieval hit. Only fields the retrieval pipeline populates are typed; + * `search_tags` is surfaced under `tags` to match the tags filter parameter. */ +export interface MatchedContext { + uri?: string; + context_type?: string; + level?: number; + abstract?: string; + score?: number; + tags?: string[]; + [key: string]: unknown; +} /** Grouped semantic retrieval results. */ export interface FindResult { - memories?: unknown[]; - resources?: unknown[]; - skills?: unknown[]; + memories?: MatchedContext[]; + resources?: MatchedContext[]; + skills?: MatchedContext[]; [key: string]: unknown; } +/** One assembled context entry. */ +export interface SearchContextEntry { + uri?: string; + category?: string; + score?: number; + detail?: string; + text?: string; + origin?: string; +} +/** Injection-ready server-side context. */ +export interface SearchContextResult { + entries?: SearchContextEntry[]; + rendered?: string; + digest?: string; + stats?: JsonObject; +} /** Error payload returned by OpenViking. */ export interface APIErrorInfo { code?: string; diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index e22f449d7d..32f7d39ebf 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -58,7 +58,155 @@ describe("OpenVikingClient", () => { }); }); - it("uses the Python/Go empty default retrieval target", async () => { + it("assembles context with dedicated options and rejects mode override", async () => { + const fetcher = vi + .fn() + .mockResolvedValue( + ok({ rendered: "", entries: [], stats: {} }), + ); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await expect( + client.searchContext("continue refactor", { + sessionId: "session-1", + purpose: "coding", + maxTokens: 3000, + dedupTurns: 5, + }), + ).resolves.toMatchObject({ rendered: "" }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + query: "continue refactor", + mode: "context", + session_id: "session-1", + purpose: "coding", + max_tokens: 3000, + dedup_turns: 5, + }); + await expect( + client.searchContext("query", { extra: { mode: "list" } }), + ).rejects.toThrow(/mode/); + }); + + it("sends latest session message and retention fields", async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => ok({})); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.addMessage("session-1", { + role: "assistant", + content: "done", + turnId: "turn-1", + messageKind: "assistant_step", + sourceMessageIds: ["user-1"], + }); + await client.commitSession("session-1", { + retentionMode: "turn_budget", + keepRecentTurnCount: 3, + retainedMessageTokenBudget: 12_000, + minRawTailSteps: 1, + }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + role: "assistant", + content: "done", + turn_id: "turn-1", + message_kind: "assistant_step", + source_message_ids: ["user-1"], + }); + expect(JSON.parse(String(fetcher.mock.calls[1]![1]?.body))).toEqual({ + retention_mode: "turn_budget", + keep_recent_turn_count: 3, + retained_message_token_budget: 12_000, + min_raw_tail_steps: 1, + }); + }); + + it("queries Agent Evolution trajectories and outcomes", async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => ok({})); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.listExperienceTrajectories("user/memories/experiences/a.md", { + limit: 25, + offset: 50, + startDate: "2026-08-01", + endDate: "2026-08-10", + }); + await client.getExperienceOutcomes("user/memories/experiences/a.md", { + startDate: "2026-08-01", + endDate: "2026-08-10", + }); + + const first = new URL(String(fetcher.mock.calls[0]![0])); + const second = new URL(String(fetcher.mock.calls[1]![0])); + expect(first.pathname).toBe( + "/api/v1/agent-evolution/experiences/trajectories", + ); + expect(first.searchParams.get("experience_uri")).toBe( + "viking://user/memories/experiences/a.md", + ); + expect(first.searchParams.get("limit")).toBe("25"); + expect(first.searchParams.get("offset")).toBe("50"); + expect(first.searchParams.get("start_date")).toBe("2026-08-01"); + expect(first.searchParams.get("end_date")).toBe("2026-08-10"); + expect(second.pathname).toBe( + "/api/v1/agent-evolution/experiences/outcomes", + ); + expect(second.searchParams.get("start_date")).toBe("2026-08-01"); + expect(second.searchParams.get("end_date")).toBe("2026-08-10"); + }); + + it("resolves and preflights OpenViking Assets with latest Git fields", async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => ok({})); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.resolveOpenVikingAssets("protocol: openviking-assets/1", { + manifestLabel: "custom.yaml", + extra: { future_flag: false }, + }); + await client.preflightOpenVikingAsset( + "private-repo", + "https://github.com/example/private.git", + { + branch: "main", + commit: "0123456789abcdef", + authConfig: { username: "oauth2", token: "secret" }, + }, + ); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + manifest_yaml: "protocol: openviking-assets/1", + manifest_label: "custom.yaml", + future_flag: false, + }); + expect(JSON.parse(String(fetcher.mock.calls[1]![1]?.body))).toEqual({ + name: "private-repo", + connector: "git", + repo_url: "https://github.com/example/private.git", + branch: "main", + commit: "0123456789abcdef", + auth_config: { username: "oauth2", token: "secret" }, + }); + }); + + it("omits unset retrieval options and uses server defaults", async () => { const fetcher = vi .fn() .mockResolvedValue(ok({ resources: [] })); @@ -69,8 +217,27 @@ describe("OpenVikingClient", () => { await client.search("hello"); - expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toMatchObject({ - target_uri: "", + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + query: "hello", + }); + }); + + it("preserves explicit zero and empty retrieval options", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(ok({ resources: [] })); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.find("hello", { limit: 0, tags: [], level: [] }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + query: "hello", + limit: 0, + tags: [], + level: [], }); }); @@ -119,6 +286,29 @@ describe("OpenVikingClient", () => { }); }); + it("sends reindex extra fields and rejects official-field overrides", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(ok({ status: "completed" })); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.reindex("resources", { + extra: { future_flag: false }, + }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toMatchObject({ + future_flag: false, + }); + expect(() => + client.reindex("resources", { + extra: { tags: ["team=search"] }, + }), + ).toThrow("extra cannot override tags"); + }); + it("sends processing_mode for addResource requests", async () => { const fetcher = vi.fn().mockResolvedValue(ok({})); const client = new OpenVikingClient({ @@ -156,15 +346,63 @@ describe("OpenVikingClient", () => { const [url, init] = fetcher.mock.calls[0]!; expect(String(url)).toBe("https://example.com/api/v1/content/write"); - expect(JSON.parse(String(init?.body))).toMatchObject({ + expect(JSON.parse(String(init?.body))).toEqual({ uri: "viking://resources/demo.md", content: "updated", - mode: "replace", processing_mode: "vectors_only", wait: true, }); }); + it("supports batch write, byte download, and resource extra", async () => { + const fetcher = vi + .fn() + .mockImplementationOnce(async () => ok({})) + .mockImplementationOnce( + async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + }), + ) + .mockImplementationOnce(async () => ok({})); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.batchWrite( + "resources/project", + [ + { + uri: "resources/project/a.txt", + content: "hello", + precondition: { kind: "create_if_absent" }, + }, + ], + { extra: { future_flag: 0 } }, + ); + await expect( + client.downloadBytes("resources/project/a.txt"), + ).resolves.toEqual(new Uint8Array([1, 2, 3])); + await client.addResource("https://example.com/a.md", { + createParent: false, + extra: { future_flag: false }, + }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toMatchObject({ + root_uri: "viking://resources/project", + future_flag: 0, + }); + expect( + new URL(String(fetcher.mock.calls[1]![0])).searchParams.get("uri"), + ).toBe("viking://resources/project/a.txt"); + expect(JSON.parse(String(fetcher.mock.calls[2]![1]?.body))).toMatchObject({ + create_parent: false, + future_flag: false, + }); + }); + it("maps response envelopes to typed errors", async () => { const fetcher = vi.fn().mockResolvedValue( new Response( @@ -241,6 +479,31 @@ describe("OpenVikingClient", () => { }); }); + it("forwards setTags extra and rejects official-field overrides", async () => { + const fetcher = vi.fn().mockResolvedValue(ok({ updated: 1 })); + const client = new OpenVikingClient({ + baseUrl: "https://example.com", + fetch: fetcher, + }); + + await client.setTags("resources/demo.md", ["team=search"], { + extra: { future_flag: false }, + }); + + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + uri: "viking://resources/demo.md", + tags: ["team=search"], + mode: "replace", + recursive: false, + future_flag: false, + }); + expect(() => + client.setTags("resources/demo.md", ["team=search"], { + extra: { uri: "viking://other" }, + }), + ).toThrow("extra cannot override uri"); + }); + it("converts an existing Node.js image path to a data URI", async () => { const directory = await mkdtemp(join(tmpdir(), "openviking-sdk-image-")); const path = join(directory, "photo.png"); @@ -322,7 +585,9 @@ describe("OpenVikingClient", () => { }); it("sends event memory tag configuration for session APIs", async () => { - const fetcher = vi.fn().mockImplementation(async () => ok({})); + const fetcher = vi + .fn() + .mockImplementation(async () => ok({})); const client = new OpenVikingClient({ baseUrl: "https://example.com", fetch: fetcher, @@ -336,7 +601,10 @@ describe("OpenVikingClient", () => { memoryExtractionConfig, autoCommitPolicy: { message_count_threshold: 25 }, }); - await client.commitSession("tagged", 0, undefined, []); + await client.commitSession("tagged", { + keepRecentCount: 0, + eventTags: [], + }); expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ session_id: "tagged", diff --git a/tests/client/test_write_signature_compat.py b/tests/client/test_write_signature_compat.py index a64c3e2bcc..d7c79c9ce0 100644 --- a/tests/client/test_write_signature_compat.py +++ b/tests/client/test_write_signature_compat.py @@ -3,31 +3,33 @@ from openviking_sdk.client import AsyncHTTPClient, SyncHTTPClient -def test_async_http_client_write_preserves_positional_telemetry(): +def test_async_http_client_write_accepts_options_as_third_argument(): bound = inspect.signature(AsyncHTTPClient.write).bind_partial( object(), "viking://resources/demo.md", "updated", - "append", - True, - 3.0, - False, + {"mode": "append", "wait": True, "timeout": 3.0, "telemetry": False}, ) - assert bound.arguments["telemetry"] is False - assert "processing_mode" not in bound.arguments + assert bound.arguments["options"] == { + "mode": "append", + "wait": True, + "timeout": 3.0, + "telemetry": False, + } -def test_sync_http_client_write_preserves_positional_telemetry(): +def test_sync_http_client_write_accepts_options_as_third_argument(): bound = inspect.signature(SyncHTTPClient.write).bind_partial( object(), "viking://resources/demo.md", "updated", - "append", - True, - 3.0, - False, + {"mode": "append", "wait": True, "timeout": 3.0, "telemetry": False}, ) - assert bound.arguments["telemetry"] is False - assert "processing_mode" not in bound.arguments + assert bound.arguments["options"] == { + "mode": "append", + "wait": True, + "timeout": 3.0, + "telemetry": False, + } diff --git a/tests/integration/langchain_langgraph/live_e2e.py b/tests/integration/langchain_langgraph/live_e2e.py index e1772209f9..5ff6331f77 100644 --- a/tests/integration/langchain_langgraph/live_e2e.py +++ b/tests/integration/langchain_langgraph/live_e2e.py @@ -520,29 +520,33 @@ def _build_real_client(): def _seed_session_context(client, session_id: str, code: str, *, framework: str) -> None: - client.create_session(session_id=session_id) + client.create_session({"session_id": session_id}) client.add_message( - session_id=session_id, - role="user", - parts=[ - { - "type": "text", - "text": ( - f"Remember this OpenViking {framework} live e2e exact code: {code}. " - "This is durable session context for the next agent turn." - ), - } - ], + session_id, + { + "role": "user", + "parts": [ + { + "type": "text", + "text": ( + f"Remember this OpenViking {framework} live e2e exact code: {code}. " + "This is durable session context for the next agent turn." + ), + } + ], + }, ) client.add_message( - session_id=session_id, - role="assistant", - parts=[ - { - "type": "text", - "text": f"Stored the OpenViking {framework} live e2e exact code: {code}.", - } - ], + session_id, + { + "role": "assistant", + "parts": [ + { + "type": "text", + "text": f"Stored the OpenViking {framework} live e2e exact code: {code}.", + } + ], + }, ) diff --git a/tests/unit/test_langchain_async_integration.py b/tests/unit/test_langchain_async_integration.py index f719889624..ec21c8b594 100644 --- a/tests/unit/test_langchain_async_integration.py +++ b/tests/unit/test_langchain_async_integration.py @@ -205,6 +205,37 @@ async def initialize(self) -> None: assert client.initialize_calls == 1 +@pytest.mark.asyncio +async def test_acall_openviking_adapts_flat_kwargs_to_sdk_options(): + calls = [] + + class OptionsClient: + async def search(self, query, options=None): + calls.append((query, options)) + return {"query": query} + + result = await acall_openviking( + OptionsClient(), + "search", + query="recover", + session_id="session-1", + limit=5, + include_provenance=False, + ) + + assert result == {"query": "recover"} + assert calls == [ + ( + "recover", + { + "session_id": "session-1", + "limit": 5, + "include_provenance": False, + }, + ) + ] + + @pytest.mark.asyncio async def test_shared_injected_async_client_initializes_once_across_real_adapters(): class SharedAsyncClient: diff --git a/tests/unit/test_langchain_integration.py b/tests/unit/test_langchain_integration.py index 0e20fdcb4c..2e8f6c0133 100644 --- a/tests/unit/test_langchain_integration.py +++ b/tests/unit/test_langchain_integration.py @@ -520,6 +520,73 @@ def find(self, query): assert result == {"query": "recover"} +def test_call_openviking_adapts_flat_kwargs_to_sdk_options(): + calls = [] + + class OptionsClient: + def find(self, query, options=None): + calls.append(("find", query, options)) + return {"query": query} + + def create_session(self, options=None): + calls.append(("create_session", options)) + return {"session_id": options["session_id"]} + + def add_message(self, session_id, message): + calls.append(("add_message", session_id, message)) + return {"ok": True} + + def write(self, uri, content, options=None): + calls.append(("write", uri, content, options)) + return {"ok": True} + + client = OptionsClient() + + call_openviking( + client, + "find", + query="recover", + target_uri="viking://resources", + limit=5, + ) + call_openviking(client, "create_session", session_id="session-1") + call_openviking( + client, + "add_message", + session_id="session-1", + role="user", + content="hello", + ) + call_openviking( + client, + "write", + uri="viking://resources/a.md", + content="hello", + mode="replace", + wait=False, + ) + + assert calls == [ + ( + "find", + "recover", + {"target_uri": "viking://resources", "limit": 5}, + ), + ("create_session", {"session_id": "session-1"}), + ( + "add_message", + "session-1", + {"role": "user", "content": "hello"}, + ), + ( + "write", + "viking://resources/a.md", + "hello", + {"mode": "replace", "wait": False}, + ), + ] + + def test_openviking_client_evicts_but_does_not_retry_mutating_call(monkeypatch): instances = []