Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
432 changes: 432 additions & 0 deletions docs/design/openviking-usage-reporter-sink-design.md

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions examples/skills/ov-experience-memory/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
name: ov-experience-memory
description: >
Use OpenViking experience memories during task execution. Search relevant
experiences with search_experience, read selected experiences with
read_experience, and leave standard tool parts in the committed session so
OpenViking can report recall and injection usage.
version: 2026.7.9
tags:
- openviking
- experience-memory
- agent-memory
- usage-reporting
---

# OpenViking Experience Memory

Use this skill when the current user request starts or continues an executable
task, especially tasks involving tools, files, code changes, data operations,
workflow decisions, or multi-step actions.

Do not use this skill for casual chat, pure explanation, or one-off factual Q&A
that does not require operational guidance.

## Runtime Contract

The agent runtime must expose two tools with these exact names:

- `search_experience`
- `read_experience`

OpenViking usage reporting recognizes only completed tool parts with these exact
tool names. Calls to generic `find`, `search`, `read`, `ov_search`, or `ov_read`
do not count as experience recall or injection events.

## Tool: search_experience

Purpose: search reusable execution experiences from the OpenViking experience
library before assembling task context.

Input schema:

```json
{
"query": "string",
"limit": 5,
"target_uri": "viking://user/memories/experiences/"
}
```

Output schema:

```json
{
"results": [
{
"uri": "viking://user/<current_user_id>/memories/experiences/example.md",
"title": "example",
"score": 0.82,
"snippet": "Short summary or matched situation"
}
]
}
```

Implementation:

Call OpenViking `POST /api/v1/search/find` with `target_uri` set to the
current-user shorthand `viking://user/memories/experiences/`. OpenViking resolves
this shorthand against the authenticated request user. Return only canonical
experience memory URIs for that user; never hardcode `default` or another user ID.

Usage reporting:

A completed `search_experience` tool part is counted as an experience recall
event for every `results[].uri` value.

## Tool: read_experience

Purpose: read the full Markdown body of a selected experience and inject it into
the agent prompt as task execution guidance.

Input schema:

```json
{
"uri": "viking://user/<current_user_id>/memories/experiences/example.md"
}
```

Output schema:

```json
{
"uri": "viking://user/<current_user_id>/memories/experiences/example.md",
"content": "Experience Markdown body"
}
```

Implementation:

Call OpenViking `GET /api/v1/content/read?uri=<encoded_uri>` for the selected
experience URI. Always pass the canonical URI returned by `search_experience`;
do not construct a URI with a hardcoded user ID. The returned content should be
inserted into the prompt as operational guidance, not as user profile facts.

Usage reporting:

A completed `read_experience` tool part is counted as an experience injection
event for `tool_input.uri` or `tool_output.uri`. In this design, reading an
experience through `read_experience` means the experience was injected into the
prompt.

## Recommended Flow

1. When a task begins, build a short query from the latest user instruction,
current plan, active skill name, and important tool/environment context.
2. Call `search_experience` before final prompt assembly.
3. Review returned titles/snippets and select only experiences likely to affect
execution.
4. Call `read_experience` for selected experience URIs.
5. Inject the returned Markdown into the prompt under an explicit experience
section.
6. Continue task execution.
7. Commit the session normally. The committed session must include the
`search_experience` and `read_experience` tool parts so OpenViking can report
usage.

## Prompt Injection Format

Use a compact and explicit block:

```text
<openviking-experience-memory>
The following guidance was retrieved from prior task execution experience.
Use it as operational guidance. Do not treat it as user identity or preference.

<experience uri="viking://user/<current_user_id>/memories/experiences/example.md">
...experience markdown...
</experience>
</openviking-experience-memory>
```

## Commit Requirements

The session committed to OpenViking must preserve tool parts with:

- `tool_name`
- `tool_status`
- `tool_input`
- `tool_output`
- `tool_id`

Only `tool_status == "completed"` is counted. Failed, cancelled, or skipped tool
parts are ignored by usage reporting.
29 changes: 23 additions & 6 deletions openviking/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,29 @@ def create_app(

validate_server_config(config)

def _configure_session_tool_outputs(service_obj) -> None: # noqa: ANN001
usage_reporter_unset = object()
usage_reporter = usage_reporter_unset

def _get_usage_reporter(): # noqa: ANN202
nonlocal usage_reporter
if usage_reporter is usage_reporter_unset:
from openviking.usage_reporter.config import build_usage_reporter

usage_reporter = build_usage_reporter(config.usage_reporter)
return usage_reporter

def _configure_session_runtime(service_obj) -> None: # noqa: ANN001
sessions = getattr(service_obj, "sessions", None)
setter = getattr(sessions, "set_tool_output_externalization_config", None)
if callable(setter):
setter(config.tool_output_externalization)
tool_output_setter = getattr(sessions, "set_tool_output_externalization_config", None)
if callable(tool_output_setter):
tool_output_setter(config.tool_output_externalization)

usage_reporter_setter = getattr(sessions, "set_usage_reporter", None)
if callable(usage_reporter_setter):
usage_reporter_setter(_get_usage_reporter())

if service is not None:
_configure_session_tool_outputs(service)
_configure_session_runtime(service)

async def _deferred_init(service, app, config):
"""Retained for tests that validate deferred-init callback behavior."""
Expand All @@ -239,7 +254,7 @@ async def lifespan(app: FastAPI):
service = OpenVikingService()

assert service is not None
_configure_session_tool_outputs(service)
_configure_session_runtime(service)
set_service(service)

from openviking.metrics.global_api import (
Expand Down Expand Up @@ -320,6 +335,8 @@ async def _oauth_gc_loop(store) -> None: # noqa: ANN001
logger.warning(f"OpenVikingService close cancelled during shutdown: {e}")
except Exception as e:
logger.warning(f"OpenVikingService close failed during shutdown: {e}")
if usage_reporter is not usage_reporter_unset and usage_reporter is not None:
await usage_reporter.close()

app = FastAPI(
title="OpenViking API",
Expand Down
21 changes: 21 additions & 0 deletions openviking/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,26 @@ class UsageAuditConfig(BaseModel):
model_config = {"extra": "forbid"}


class UsageReporterSinkConfig(BaseModel):
"""Usage reporter sink configuration."""

type: Literal["custom"] = "custom"
class_path: Optional[str] = None
config: Dict[str, object] = Field(default_factory=dict)

model_config = {"extra": "forbid"}


class UsageReporterConfig(BaseModel):
"""Usage event reporter configuration."""

enabled: bool = False
extractors: List[Literal["memory_usage"]] = Field(default_factory=lambda: ["memory_usage"])
sinks: List[UsageReporterSinkConfig] = Field(default_factory=list)

model_config = {"extra": "forbid"}


class TraceDumpBodyConfig(BaseModel):
"""HTTP body dump configuration.

Expand Down Expand Up @@ -234,6 +254,7 @@ class ServerConfig(BaseModel):
encryption_enabled: bool = False # Whether file-level AES encryption is enabled
api_key_hashing_enabled: bool = False # Whether API key Argon2id hashing is enabled (default: false, rely on file encryption)
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
usage_reporter: UsageReporterConfig = Field(default_factory=UsageReporterConfig)
# Public-facing base URL emitted in MCP-issued upload instructions. See
# ``openviking.server.mcp_endpoint._resolve_public_base_url`` for the full
# resolution chain: env var > this field > X-Forwarded-Host/Proto > Host header
Expand Down
7 changes: 7 additions & 0 deletions openviking/service/session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

if TYPE_CHECKING:
from openviking.session.compressor_v2 import SessionCompressorV2
from openviking.usage_reporter import UsageReporter


class SessionService:
Expand All @@ -43,6 +44,7 @@ def __init__(
self._viking_fs = viking_fs
self._session_compressor = session_compressor
self._tool_output_externalization_config = ToolOutputExternalizationConfig()
self._usage_reporter: Optional["UsageReporter"] = None

def set_dependencies(
self,
Expand All @@ -61,6 +63,10 @@ def set_tool_output_externalization_config(
"""Set tool output externalization controls for newly created sessions."""
self._tool_output_externalization_config = config.model_copy(deep=True)

def set_usage_reporter(self, usage_reporter: Optional["UsageReporter"]) -> None:
"""Set the usage reporter for newly created sessions."""
self._usage_reporter = usage_reporter

def _ensure_initialized(self) -> None:
"""Ensure all dependencies are initialized."""
if not self._viking_fs:
Expand Down Expand Up @@ -120,6 +126,7 @@ def session(
session_id=session_id,
session_uri=session_uri,
tool_output_externalization_config=self._tool_output_externalization_config,
usage_reporter=self._usage_reporter,
)

async def create(
Expand Down
34 changes: 34 additions & 0 deletions openviking/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from openviking.session.compressor_v2 import SessionCompressorV2 as SessionCompressor
from openviking.storage import VikingDBManager
from openviking.storage.viking_fs import VikingFS
from openviking.usage_reporter import UsageReporter

logger = get_logger(__name__)

Expand Down Expand Up @@ -361,6 +362,7 @@ def __init__(
session_uri: Optional[str] = None,
auto_commit_threshold: int = 8000,
tool_output_externalization_config: Optional[ToolOutputExternalizationConfig] = None,
usage_reporter: Optional["UsageReporter"] = None,
):
self._viking_fs = viking_fs
self._vikingdb_manager = vikingdb_manager
Expand Down Expand Up @@ -388,6 +390,7 @@ def __init__(
if tool_output_externalization_config is not None
else ToolOutputExternalizationConfig()
)
self._usage_reporter = usage_reporter

logger.info(f"Session created: {self.session_id} for user {self.user}")

Expand Down Expand Up @@ -1263,6 +1266,28 @@ async def commit_async(
"trace_id": trace_id,
}

async def _run_usage_reporting(
self,
*,
task_id: str,
archive_uri: str,
messages: List[Message],
) -> list[Any]:
reporter = getattr(self, "_usage_reporter", None)
if reporter is None:
return []

from openviking.usage_reporter import UsageContext

context = UsageContext(
account_id=self.ctx.account_id,
user_id=self.ctx.user.user_id,
session_id=self.session_id,
archive_uri=archive_uri,
task_id=task_id,
)
return await reporter.extract_and_report(messages=messages, context=context)

@tracer("session.commit.phase2", ignore_result=True, ignore_args=True)
async def _run_memory_extraction(
self,
Expand All @@ -1286,6 +1311,7 @@ async def _run_memory_extraction(
request_wait_tracker = get_request_wait_tracker()

memories_extracted: Dict[str, int] = {}
usage_events_extracted = 0
extracted_skill_results: list[dict] = []
active_count_updated = 0
memory_diff_uri: Optional[str] = None
Expand Down Expand Up @@ -1333,6 +1359,13 @@ async def _run_memory_extraction(
else ""
)
extraction_messages = await self._hydrate_tool_outputs_for_extraction(messages)
usage_events_extracted = len(
await self._run_usage_reporting(
task_id=task_id,
archive_uri=archive_uri,
messages=extraction_messages,
)
)

async def _run_archive_summary() -> None:
if not working_memory_enabled:
Expand Down Expand Up @@ -1636,6 +1669,7 @@ async def _run_execution_memory_extraction() -> Any:
for item in extracted_skill_results
if isinstance(item, dict) and (item.get("uri") or item.get("root_uri"))
],
"usage_events_extracted": usage_events_extracted,
"active_count_updated": active_count_updated,
"token_usage": {
"llm": dict(self._meta.llm_token_usage),
Expand Down
17 changes: 17 additions & 0 deletions openviking/usage_reporter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Usage reporting extension points for OpenViking."""

from .extractors import MemoryUsageExtractor, UsageExtractor
from .models import UsageContext, UsageEvent
from .reporter import UsageReporter
from .sinks import UsageSink

__all__ = [
"MemoryUsageExtractor",
"UsageContext",
"UsageEvent",
"UsageExtractor",
"UsageReporter",
"UsageSink",
]
Loading