From eee816f7ef515f847b3a85f1106f8c037fb31bc6 Mon Sep 17 00:00:00 2001 From: qin-ctx Date: Tue, 31 Mar 2026 12:20:01 +0800 Subject: [PATCH] fix(session): fold latest archive into abstract list Return the latest archive through pre_archive_abstracts and drop the separate latest_archive_id field so session context consumers only need one archive index path. --- crates/ov_cli/src/output.rs | 20 ++++------- docs/en/api/05-sessions.md | 16 ++++----- docs/zh/api/05-sessions.md | 16 ++++----- .../__tests__/context-engine-assemble.test.ts | 23 ++++++++----- examples/openclaw-plugin/client.ts | 1 - examples/openclaw-plugin/context-engine.ts | 16 +++------ openviking/session/session.py | 7 +--- tests/server/test_api_sessions.py | 2 -- tests/server/test_http_client_sdk.py | 1 - tests/session/test_session_context.py | 34 ++++++++----------- 10 files changed, 56 insertions(+), 80 deletions(-) diff --git a/crates/ov_cli/src/output.rs b/crates/ov_cli/src/output.rs index 017cd3c19..279eb89b4 100644 --- a/crates/ov_cli/src/output.rs +++ b/crates/ov_cli/src/output.rs @@ -364,17 +364,12 @@ fn render_session_context( compact: bool, ) -> Option { if !(obj.contains_key("latest_archive_overview") - && obj.contains_key("latest_archive_id") && obj.contains_key("pre_archive_abstracts") && obj.contains_key("messages")) { return None; } - let latest_archive_id = obj - .get("latest_archive_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); let latest_archive_overview = obj .get("latest_archive_overview") .and_then(|v| v.as_str()) @@ -385,14 +380,6 @@ fn render_session_context( .unwrap_or_else(|| "0".to_string()); let mut lines: Vec = Vec::new(); - lines.push(format!( - "latest_archive_id {}", - if latest_archive_id.is_empty() { - "(none)" - } else { - latest_archive_id - } - )); lines.push(format!("estimated_tokens {}", estimated_tokens)); if let Some(stats) = obj.get("stats").and_then(|v| v.as_object()) { @@ -426,7 +413,12 @@ fn render_session_context( lines.push(String::new()); lines.push("latest_archive_overview".to_string()); if latest_archive_overview.is_empty() { - if latest_archive_id.is_empty() { + let has_abstracts = obj + .get("pre_archive_abstracts") + .and_then(|v| v.as_array()) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if !has_abstracts { lines.push("(none)".to_string()); } else { lines.push("(trimmed by token budget or unavailable)".to_string()); diff --git a/docs/en/api/05-sessions.md b/docs/en/api/05-sessions.md index 8ce60c0d3..485569911 100644 --- a/docs/en/api/05-sessions.md +++ b/docs/en/api/05-sessions.md @@ -182,14 +182,12 @@ Get the assembled session context used by OpenClaw-style context rebuilding. This endpoint returns: - `latest_archive_overview`: the `overview` of the latest completed archive, when it fits the token budget -- `latest_archive_id`: the ID of the latest completed archive, used for archive expansion -- `pre_archive_abstracts`: lightweight history entries for older completed archives, each containing `archive_id` and `abstract` +- `pre_archive_abstracts`: lightweight entries for completed archives, each containing `archive_id` and `abstract` - `messages`: all incomplete archive messages after the latest completed archive, plus current live session messages - `stats`: token and inclusion stats for the returned context Notes: - `latest_archive_overview` becomes an empty string when no completed archive exists, or when the latest overview does not fit in the token budget. -- `latest_archive_id` is returned whenever a latest completed archive exists, even if `latest_archive_overview` is trimmed by budget. - `token_budget` is applied to the assembled payload after active `messages`: `latest_archive_overview` has higher priority than `pre_archive_abstracts`, and older abstracts are dropped first when budget is tight. - Only archive content that is actually returned is counted toward `estimatedTokens` and `stats.archiveTokens`. - Session commit generates an archive summary during Phase 2 for every non-empty archive attempt. Only archives with a completed `.done` marker are exposed here. @@ -206,7 +204,6 @@ Notes: ```python context = await client.get_session_context("a1b2c3d4", token_budget=128000) print(context["latest_archive_overview"]) -print(context["latest_archive_id"]) print(context["pre_archive_abstracts"]) print(len(context["messages"])) @@ -238,8 +235,11 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 "status": "ok", "result": { "latest_archive_overview": "# Session Summary\n\n**Overview**: User discussed deployment and auth setup.", - "latest_archive_id": "archive_002", "pre_archive_abstracts": [ + { + "archive_id": "archive_002", + "abstract": "User discussed deployment and authentication setup." + }, { "archive_id": "archive_001", "abstract": "User previously discussed repository bootstrap and authentication setup." @@ -263,14 +263,14 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 "created_at": "2026-03-24T09:10:20Z" } ], - "estimatedTokens": 160, + "estimatedTokens": 173, "stats": { "totalArchives": 2, "includedArchives": 2, "droppedArchives": 0, "failedArchives": 0, "activeTokens": 98, - "archiveTokens": 62 + "archiveTokens": 75 } } } @@ -282,7 +282,7 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 Get the full contents of one completed archive for a session. -This endpoint is intended to work with `latest_archive_id` and `pre_archive_abstracts[*].archive_id` returned by `get_session_context()`. +This endpoint is intended to work with `pre_archive_abstracts[*].archive_id` returned by `get_session_context()`. This endpoint returns: - `archive_id`: the archive ID that was expanded diff --git a/docs/zh/api/05-sessions.md b/docs/zh/api/05-sessions.md index 4e294eb09..41b5b06a5 100644 --- a/docs/zh/api/05-sessions.md +++ b/docs/zh/api/05-sessions.md @@ -182,14 +182,12 @@ openviking session get a1b2c3d4 该接口返回: - `latest_archive_overview`:最新一个已完成归档的 `overview` 文本,在 token budget 足够时返回 -- `latest_archive_id`:最新一个已完成归档的 ID,用于后续展开 archive 详情 -- `pre_archive_abstracts`:更早历史归档的轻量列表,每项只包含 `archive_id` 和 `abstract` +- `pre_archive_abstracts`:已完成归档的轻量列表,每项只包含 `archive_id` 和 `abstract` - `messages`:最新已完成归档之后的所有未完成归档消息,再加上当前 live session 消息 - `stats`:返回结果对应的 token 与纳入统计 说明: - 没有可用 completed archive,或最新 overview 超出 token budget 时,`latest_archive_overview` 返回空字符串。 -- 只要存在最新 completed archive,就会返回 `latest_archive_id`;即使 `latest_archive_overview` 因 budget 被裁剪,这个 ID 仍然可用。 - `token_budget` 会在 active `messages` 之后作用于 assembled archive payload:`latest_archive_overview` 优先级高于 `pre_archive_abstracts`,预算紧张时先淘汰最旧的 abstracts。 - 只有最终实际返回的 archive 内容,才会计入 `estimatedTokens` 和 `stats.archiveTokens`。 - 当前每次有消息的 session commit 都会在 Phase 2 生成 archive 摘要;只有带 `.done` 标记的 completed archive 才会被这里返回。 @@ -206,7 +204,6 @@ openviking session get a1b2c3d4 ```python context = await client.get_session_context("a1b2c3d4", token_budget=128000) print(context["latest_archive_overview"]) -print(context["latest_archive_id"]) print(context["pre_archive_abstracts"]) print(len(context["messages"])) @@ -238,8 +235,11 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 "status": "ok", "result": { "latest_archive_overview": "# Session Summary\n\n**Overview**: User discussed deployment and auth setup.", - "latest_archive_id": "archive_002", "pre_archive_abstracts": [ + { + "archive_id": "archive_002", + "abstract": "用户讨论了部署和鉴权配置。" + }, { "archive_id": "archive_001", "abstract": "用户之前讨论了仓库初始化和鉴权配置。" @@ -263,14 +263,14 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 "created_at": "2026-03-24T09:10:20Z" } ], - "estimatedTokens": 147, + "estimatedTokens": 160, "stats": { "totalArchives": 2, "includedArchives": 2, "droppedArchives": 0, "failedArchives": 0, "activeTokens": 98, - "archiveTokens": 49 + "archiveTokens": 62 } } } @@ -282,7 +282,7 @@ ov session get-session-context a1b2c3d4 --token-budget 128000 获取某次已完成归档的完整内容。 -该接口通常配合 `get_session_context()` 返回的 `latest_archive_id` 或 `pre_archive_abstracts[*].archive_id` 使用。 +该接口通常配合 `get_session_context()` 返回的 `pre_archive_abstracts[*].archive_id` 使用。 该接口返回: - `archive_id`:被展开的 archive ID diff --git a/examples/openclaw-plugin/__tests__/context-engine-assemble.test.ts b/examples/openclaw-plugin/__tests__/context-engine-assemble.test.ts index de1c17e94..bbdb140ed 100644 --- a/examples/openclaw-plugin/__tests__/context-engine-assemble.test.ts +++ b/examples/openclaw-plugin/__tests__/context-engine-assemble.test.ts @@ -66,8 +66,12 @@ describe("context-engine assemble()", () => { it("assembles summary archive and completed tool parts into agent messages", async () => { const { engine, client, resolveAgentId } = makeEngine({ latest_archive_overview: "# Session Summary\nPreviously discussed repository setup.", - latest_archive_id: "archive_001", - pre_archive_abstracts: [], + pre_archive_abstracts: [ + { + archive_id: "archive_001", + abstract: "Previously discussed repository setup.", + }, + ], messages: [ { id: "msg_1", @@ -104,14 +108,18 @@ describe("context-engine assemble()", () => { tokenBudget: 4096, }); - expect(resolveAgentId).toHaveBeenCalledWith("session-1"); + expect(resolveAgentId).toHaveBeenCalledWith("session-1", undefined, "session-1"); expect(client.getSessionContext).toHaveBeenCalledWith("session-1", 4096, "agent:session-1"); expect(result.estimatedTokens).toBe(321); - expect(result.systemPromptAddition).toContain("Compressed Context"); + expect(result.systemPromptAddition).toContain("Session Context Guide"); expect(result.messages).toEqual([ { role: "user", - content: "# Session Summary\nPreviously discussed repository setup.", + content: "[Session History Summary]\n# Session Summary\nPreviously discussed repository setup.", + }, + { + role: "user", + content: "[Archive Index]\narchive_001: Previously discussed repository setup.", }, { role: "assistant", @@ -139,7 +147,6 @@ describe("context-engine assemble()", () => { it("emits a non-error toolResult for a running tool (not a synthetic error)", async () => { const { engine } = makeEngine({ latest_archive_overview: "", - latest_archive_id: "", pre_archive_abstracts: [], messages: [ { @@ -191,7 +198,7 @@ describe("context-engine assemble()", () => { }); const text = (result.messages[1] as any).content?.[0]?.text ?? ""; expect(text).toContain("interrupted"); - expect((result.messages[1] as { content: Array<{ text: string }> }).content[0]?.text).toContain( + expect((result.messages[1] as { content: Array<{ text: string }> }).content[0]?.text).not.toContain( "missing tool result", ); }); @@ -199,7 +206,6 @@ describe("context-engine assemble()", () => { it("degrades tool parts without tool_id into assistant text blocks", async () => { const { engine } = makeEngine({ latest_archive_overview: "", - latest_archive_id: "", pre_archive_abstracts: [], messages: [ { @@ -248,7 +254,6 @@ describe("context-engine assemble()", () => { it("falls back to live messages when assembled active messages look truncated", async () => { const { engine } = makeEngine({ latest_archive_overview: "", - latest_archive_id: "", pre_archive_abstracts: [], messages: [ { diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts index aba3e488b..56440356c 100644 --- a/examples/openclaw-plugin/client.ts +++ b/examples/openclaw-plugin/client.ts @@ -86,7 +86,6 @@ export type PreArchiveAbstract = { export type SessionContextResult = { latest_archive_overview: string; - latest_archive_id: string; pre_archive_abstracts: PreArchiveAbstract[]; messages: OVMessage[]; estimatedTokens: number; diff --git a/examples/openclaw-plugin/context-engine.ts b/examples/openclaw-plugin/context-engine.ts index 8154e47e9..dca39a836 100644 --- a/examples/openclaw-plugin/context-engine.ts +++ b/examples/openclaw-plugin/context-engine.ts @@ -596,9 +596,9 @@ export function createMemoryOpenVikingContextEngine(params: { agentId, ); - const hasArchives = !!ctx?.latest_archive_id; - const activeCount = ctx?.messages?.length ?? 0; const preAbstracts = ctx?.pre_archive_abstracts ?? []; + const hasArchives = !!ctx?.latest_archive_overview || preAbstracts.length > 0; + const activeCount = ctx?.messages?.length ?? 0; if (!ctx || (!hasArchives && activeCount === 0)) { diag("assemble_result", OVSessionId, { @@ -633,15 +633,10 @@ export function createMemoryOpenVikingContextEngine(params: { }); } - if (preAbstracts.length > 0 || ctx.latest_archive_id) { + if (preAbstracts.length > 0) { const lines: string[] = preAbstracts.map( (a) => `${a.archive_id}: ${a.abstract}`, ); - if (ctx.latest_archive_id) { - lines.push( - `(latest: ${ctx.latest_archive_id} — see [Session History Summary] above)`, - ); - } assembled.push({ role: "user" as const, content: `[Archive Index]\n${lines.join("\n")}`, @@ -656,7 +651,7 @@ export function createMemoryOpenVikingContextEngine(params: { if (sanitized.length === 0 && messages.length > 0) { diag("assemble_result", OVSessionId, { passthrough: true, reason: "sanitized_empty", - archiveCount: preAbstracts.length + (ctx.latest_archive_id ? 1 : 0), + archiveCount: preAbstracts.length, activeCount, outputMessagesCount: messages.length, inputTokenEstimate: originalTokens, @@ -667,7 +662,7 @@ export function createMemoryOpenVikingContextEngine(params: { } const assembledTokens = roughEstimate(sanitized); - const archiveCount = preAbstracts.length + (ctx.latest_archive_id ? 1 : 0); + const archiveCount = preAbstracts.length; const tokensSaved = originalTokens - assembledTokens; const savingPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0; @@ -680,7 +675,6 @@ export function createMemoryOpenVikingContextEngine(params: { estimatedTokens: assembledTokens, tokensSaved, savingPct, - latestArchiveId: ctx.latest_archive_id ?? null, messages: messageDigest(sanitized), }); diff --git a/openviking/session/session.py b/openviking/session/session.py index 3845b9d04..6c3ca03a4 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -740,9 +740,7 @@ async def get_session_context(self, token_budget: int = 128_000) -> Dict[str, An remaining_budget -= item["tokens"] archive_tokens = latest_archive_tokens + pre_archive_tokens - included_archives = (1 if include_latest_overview else 0) + len( - included_pre_archive_abstracts - ) + included_archives = len(included_pre_archive_abstracts) dropped_archives = max( 0, context["total_archives"] - context["failed_archives"] - included_archives ) @@ -751,7 +749,6 @@ async def get_session_context(self, token_budget: int = 128_000) -> Dict[str, An "latest_archive_overview": ( latest_archive["overview"] if include_latest_overview else "" ), - "latest_archive_id": latest_archive["archive_id"] if latest_archive else "", "pre_archive_abstracts": included_pre_archive_abstracts, "messages": [m.to_dict() for m in merged_messages], "estimatedTokens": message_tokens + archive_tokens, @@ -835,8 +832,6 @@ async def _collect_session_context_components(self) -> Dict[str, Any]: archive["archive_uri"], overview ), } - continue - abstract = await self._read_archive_abstract(archive["archive_uri"]) if abstract: pre_archive_abstracts.append( diff --git a/tests/server/test_api_sessions.py b/tests/server/test_api_sessions.py index 2288884d8..c15a476d3 100644 --- a/tests/server/test_api_sessions.py +++ b/tests/server/test_api_sessions.py @@ -107,7 +107,6 @@ async def test_get_session_context(client: httpx.AsyncClient): body = resp.json() assert body["status"] == "ok" assert body["result"]["latest_archive_overview"] == "" - assert body["result"]["latest_archive_id"] == "" assert body["result"]["pre_archive_abstracts"] == [] assert [m["parts"][0]["text"] for m in body["result"]["messages"]] == ["Current live message"] @@ -331,7 +330,6 @@ async def test_get_session_context_endpoint_returns_trimmed_latest_archive_and_m result = body["result"] assert result["latest_archive_overview"] == "" - assert result["latest_archive_id"] == "archive_001" assert result["pre_archive_abstracts"] == [] assert len(result["messages"]) == 1 assert result["messages"][0]["role"] == "assistant" diff --git a/tests/server/test_http_client_sdk.py b/tests/server/test_http_client_sdk.py index 1d8418825..530694f9f 100644 --- a/tests/server/test_http_client_sdk.py +++ b/tests/server/test_http_client_sdk.py @@ -150,7 +150,6 @@ async def test_sdk_session_lifecycle(http_client): context = await client.get_session_context(session_id) assert context["latest_archive_overview"] == "" - assert context["latest_archive_id"] == "" assert context["pre_archive_abstracts"] == [] assert [m["parts"][0]["text"] for m in context["messages"]] == ["Hello from SDK"] diff --git a/tests/session/test_session_context.py b/tests/session/test_session_context.py index 27785db64..878270c64 100644 --- a/tests/session/test_session_context.py +++ b/tests/session/test_session_context.py @@ -364,15 +364,14 @@ async def fake_generate(_messages, latest_archive_overview=""): context = await session.get_session_context(token_budget=token_budget) assert context["latest_archive_overview"] == newest_summary - assert context["latest_archive_id"] == "archive_002" assert context["pre_archive_abstracts"] == [] assert len(context["messages"]) == 1 assert context["messages"][0]["parts"][0]["text"] == "active tail" assert context["estimatedTokens"] == token_budget assert context["stats"] == { "totalArchives": 2, - "includedArchives": 1, - "droppedArchives": 1, + "includedArchives": 0, + "droppedArchives": 2, "failedArchives": 0, "activeTokens": active_tokens, "archiveTokens": _estimate_tokens(newest_summary), @@ -391,10 +390,10 @@ async def test_get_session_context_counts_active_tool_parts( assert context["stats"]["activeTokens"] == session.messages[0].estimated_tokens assert context["stats"]["activeTokens"] > _estimate_tokens("Executing tool...") - async def test_get_session_context_reads_latest_overview_and_previous_abstracts( + async def test_get_session_context_reads_latest_overview_and_all_archive_abstracts( self, client: AsyncOpenViking, monkeypatch ): - """Overview should only be read for the latest archive; older archives use abstracts.""" + """Overview is only read for the latest archive; abstracts are returned for all archives.""" session = client.session(session_id="assemble_lazy_read_test") summaries = [ "# Summary\n\n" + ("A" * 80), @@ -438,8 +437,8 @@ async def tracking_read_file(*args, **kwargs): context = await session.get_session_context(token_budget=token_budget) assert context["latest_archive_overview"] == newest_summary - assert context["latest_archive_id"] == "archive_003" assert context["pre_archive_abstracts"] == [ + {"archive_id": "archive_003", "abstract": "# Summary"}, {"archive_id": "archive_002", "abstract": "# Summary"}, {"archive_id": "archive_001", "abstract": "# Summary"}, ] @@ -452,15 +451,11 @@ async def tracking_read_file(*args, **kwargs): f"Only newest archive overview should be read, got: {overview_reads}" ) assert all( - "archive_003" not in u and ("archive_002" in u or "archive_001" in u) - for u in abstract_reads - ), f"Only previous archive abstracts should be read, got: {abstract_reads}" + "archive_003" in u or "archive_002" in u or "archive_001" in u for u in abstract_reads + ), f"Archive abstracts should be read for every returned archive, got: {abstract_reads}" assert not any("archive_001/.overview.md" in u for u in overview_reads), ( "Oldest archive overview should not be read" ) - assert not any("archive_003/.abstract.md" in u for u in abstract_reads), ( - "Latest archive abstract should not be read for context history" - ) async def test_get_session_context_drops_oldest_pre_archive_abstracts_first( self, client: AsyncOpenViking, monkeypatch @@ -496,14 +491,13 @@ async def fake_generate(_messages, latest_archive_overview=""): context = await session.get_session_context(token_budget=token_budget) assert context["latest_archive_overview"] == newest_summary - assert context["latest_archive_id"] == "archive_003" assert context["pre_archive_abstracts"] == [ - {"archive_id": "archive_002", "abstract": "# Summary"} + {"archive_id": "archive_003", "abstract": "# Summary"} ] assert context["estimatedTokens"] == token_budget assert context["stats"]["totalArchives"] == 3 - assert context["stats"]["includedArchives"] == 2 - assert context["stats"]["droppedArchives"] == 1 + assert context["stats"]["includedArchives"] == 1 + assert context["stats"]["droppedArchives"] == 2 async def test_get_session_context_falls_back_to_older_completed_archive( self, client: AsyncOpenViking, monkeypatch @@ -541,14 +535,15 @@ async def flaky_read_file(*args, **kwargs): context = await session.get_session_context(token_budget=128_000) assert context["latest_archive_overview"] == "# Session Summary\n\narchive one" - assert context["latest_archive_id"] == "archive_001" - assert context["pre_archive_abstracts"] == [] + assert context["pre_archive_abstracts"] == [ + {"archive_id": "archive_001", "abstract": "# Session Summary"} + ] assert context["stats"]["totalArchives"] == 2 assert context["stats"]["includedArchives"] == 1 assert context["stats"]["droppedArchives"] == 0 assert context["stats"]["failedArchives"] == 1 - async def test_get_session_context_budget_trim_keeps_latest_archive_id( + async def test_get_session_context_budget_trim_drops_latest_archive_abstract( self, client: AsyncOpenViking, monkeypatch ): session = client.session(session_id="assemble_trim_id_test") @@ -566,7 +561,6 @@ async def fake_generate(_messages, latest_archive_overview=""): context = await session.get_session_context(token_budget=1) assert context["latest_archive_overview"] == "" - assert context["latest_archive_id"] == "archive_001" assert context["pre_archive_abstracts"] == [] assert context["stats"]["includedArchives"] == 0 assert context["stats"]["droppedArchives"] == 1