feat(anchor): add inert turn registry skeleton

This commit is contained in:
Frank Song committed 2026-06-12 15:52:28 +08:00
1 parent 9e96f5f6ad
commit 56a89ef11e
4 files changed
+363 -6

No files matched your search

@@ -37,6 +37,36 @@ same event-envelope key. This is still inert: `send()`, `attachLiveStream()`,
`renderMessages()`, settlement restore, `S.messages`, `INFLIGHT`, and the DOM do `renderMessages()`, settlement restore, `S.messages`, `INFLIGHT`, and the DOM do
not consume the helper yet. not consume the helper yet.
## Slice 3 Registry / Owner Skeleton
`HermesAssistantTurnAnchors.createAssistantTurnAnchorRegistry()` creates a local
owner object for one assistant turn. The registry contains the anchor seed, a
dedupe index, and application stats. It is not a global store and is not wired
into current runtime, session, or renderer code.
`HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent()` and
`applyAssistantTurnAnchorSourceEvents()` normalize incoming source events, apply
the same event-envelope dedupe rule, and route events into one owner:
- `activity_events` for visible assistant activity such as prose, reasoning,
tools, control boundaries, and terminal status
- `artifacts` for workspace/file references
- `side_effects` for persisted state side effects
- `metadata_events` for settlement/session metadata such as `settled_message`
- `transport_events` for transport-only signals such as `stream_end`
The registry may fill missing `run_id` / `stream_id` identity from the first
matching normalized event, update lifecycle on terminal status, and copy the
settled assistant message into `content.final_answer`. It rejects mismatched
session or turn identity and skips duplicate live + replay observations by the
same dedupe key.
This slice deliberately keeps the ownership boundary inert: `send()`,
`attachLiveStream()`, replay hydration, `renderMessages()`, `S.messages`,
`INFLIGHT`, and DOM continuity still do not consume the registry. Later slices
can replace local renderer-owned state with this owner instead of adding another
parallel source of truth.
## Source Event Classification ## Source Event Classification
Phase 0 classifies current sources before changing render behavior: Phase 0 classifies current sources before changing render behavior:
+183 -3
View File
@@ -1,8 +1,8 @@
// Stable Assistant Turn Anchors scaffold (#3926). // Stable Assistant Turn Anchors scaffold (#3926).
// //
// This file is intentionally inert: it defines the current ownership inventory, // This file is intentionally inert: it defines the current ownership inventory,
// event classifications, and small pure helpers, but it does not register // event classifications, and small owner helpers, but it does not register
// anchors or change any renderer. Later phases can wire these helpers into // anchors globally or change any renderer. Later phases can wire these helpers into
// send(), attachLiveStream(), replay hydration, and renderMessages(). // send(), attachLiveStream(), replay hydration, and renderMessages().
(function(){ (function(){
const ROOT=(typeof window!=='undefined')?window:globalThis; const ROOT=(typeof window!=='undefined')?window:globalThis;
@@ -374,6 +374,163 @@
return out; return out;
} }
function _copyObject(value){
if(!value||typeof value!=='object'||Array.isArray(value)) return {};
return {...value};
}
function _registryAnchor(registry){
return registry&&typeof registry==='object'&&registry.anchor&&typeof registry.anchor==='object'
?registry.anchor
:null;
}
function _registryContext(registry, context){
const anchor=_registryAnchor(registry);
const identity=anchor&&anchor.identity?anchor.identity:{};
return {
..._copyObject(context),
session_id:identity.session_id||_cleanString(context&&context.session_id),
turn_id:identity.turn_id||_cleanString(context&&context.turn_id),
run_id:identity.run_id||_cleanString(context&&context.run_id),
stream_id:identity.stream_id||_cleanString(context&&context.stream_id),
};
}
function _eventBelongsToAnchor(anchor, event){
const identity=anchor.identity||{};
const sessionId=_cleanString(event.session_id);
if(sessionId&&identity.session_id&&sessionId!==identity.session_id) return false;
const turnId=_cleanString(event.turn_id);
if(turnId&&identity.turn_id&&turnId!==identity.turn_id) return false;
return true;
}
function _ensureRegistryShape(registry){
const anchor=_registryAnchor(registry);
if(!anchor) throw new Error('assistant turn anchor registry requires anchor');
registry.event_index=registry.event_index&&typeof registry.event_index==='object'
?registry.event_index
:{};
if(!Array.isArray(registry.event_index.dedupe_keys)) registry.event_index.dedupe_keys=[];
registry.stats=registry.stats&&typeof registry.stats==='object'?registry.stats:{};
registry.stats.applied=Number(registry.stats.applied)||0;
registry.stats.skipped_duplicate=Number(registry.stats.skipped_duplicate)||0;
registry.stats.skipped_excluded=Number(registry.stats.skipped_excluded)||0;
registry.stats.skipped_mismatched=Number(registry.stats.skipped_mismatched)||0;
if(!Array.isArray(anchor.metadata_events)) anchor.metadata_events=[];
if(!Array.isArray(anchor.transport_events)) anchor.transport_events=[];
return anchor;
}
function _syncAnchorIdentity(anchor, event){
const identity=anchor.identity||{};
if(!identity.run_id&&_cleanString(event.run_id)) identity.run_id=_cleanString(event.run_id);
if(!identity.stream_id&&_cleanString(event.stream_id)) identity.stream_id=_cleanString(event.stream_id);
}
function _firstTextValue(){
for(let i=0;i<arguments.length;i+=1){
const value=arguments[i];
if(typeof value==='string'&&value.length>0) return value;
}
return '';
}
function _messageRefFromPayload(payload, event){
return _firstTextValue(
payload&&payload.message_id,
payload&&payload.id,
payload&&payload.local_id,
event&&event.local_id,
event&&event.event_id
)||null;
}
function _updateLifecycleFromEvent(anchor, event){
const lifecycle=anchor.lifecycle||{};
if(!lifecycle.started_at&&event.created_at) lifecycle.started_at=event.created_at;
if((!lifecycle.status||lifecycle.status==='created')&&event.status==='running'){
lifecycle.status='running';
}
if(event.kind==='terminal_status'){
const terminal=_cleanString(event.status)||'completed';
lifecycle.status=terminal;
lifecycle.terminal_state=terminal;
lifecycle.completed_at=event.created_at||lifecycle.completed_at||null;
}
anchor.lifecycle=lifecycle;
}
function _updateContentFromMetadata(anchor, event){
const payload=event.payload||{};
if(event.source_event_type==='usage'){
anchor.usage=_copyObject(payload);
return;
}
if(event.source_event_type!=='settled_message') return;
if(payload.role&&payload.role!=='assistant') return;
const finalAnswer=_firstTextValue(payload.content,payload.text,payload.final_answer,payload.answer);
if(finalAnswer){
anchor.content=anchor.content||{};
anchor.content.final_answer=finalAnswer;
anchor.content.final_message_ref=_messageRefFromPayload(payload,event);
}
if(payload._turnUsage&&typeof payload._turnUsage==='object') anchor.usage=_copyObject(payload._turnUsage);
if(payload.usage&&typeof payload.usage==='object') anchor.usage=_copyObject(payload.usage);
}
function _routeAnchorEvent(anchor, normalized){
const event=normalized.anchor_event;
if(normalized.classification==='activity'){
anchor.activity_events.push(event);
_updateLifecycleFromEvent(anchor,event);
}else if(normalized.classification==='artifact'){
anchor.artifacts.push(event);
}else if(normalized.classification==='side_effect'){
anchor.side_effects.push(event);
}else if(normalized.classification==='metadata'){
anchor.metadata_events.push(event);
_updateContentFromMetadata(anchor,event);
}else if(normalized.classification==='transport'){
anchor.transport_events.push(event);
}
}
function applyAssistantTurnAnchorNormalizedEvent(registry, normalized){
const anchor=_ensureRegistryShape(registry);
const item=(normalized&&typeof normalized==='object')?normalized:{};
const event=item.anchor_event;
if(!event){
registry.stats.skipped_excluded+=1;
return Object.freeze({applied:false,reason:'excluded',normalized:item});
}
if(!_eventBelongsToAnchor(anchor,event)){
registry.stats.skipped_mismatched+=1;
return Object.freeze({applied:false,reason:'mismatched_anchor',normalized:item});
}
const dedupeKey=_cleanString(item.dedupe_key)||assistantTurnAnchorEventDedupeKey(event);
if(dedupeKey&&registry.event_index.dedupe_keys.indexOf(dedupeKey)!==-1){
registry.stats.skipped_duplicate+=1;
return Object.freeze({applied:false,reason:'duplicate',normalized:item});
}
if(dedupeKey) registry.event_index.dedupe_keys.push(dedupeKey);
_syncAnchorIdentity(anchor,event);
_routeAnchorEvent(anchor,item);
registry.stats.applied+=1;
return Object.freeze({applied:true,reason:null,normalized:item});
}
function applyAssistantTurnAnchorSourceEvent(registry, input, context){
const normalized=normalizeAssistantTurnAnchorSourceEvent(input,_registryContext(registry,context));
return applyAssistantTurnAnchorNormalizedEvent(registry,normalized);
}
function applyAssistantTurnAnchorSourceEvents(registry, events, context){
const list=Array.isArray(events)?events:[];
return list.map((event)=>applyAssistantTurnAnchorSourceEvent(registry,event,context));
}
function createAssistantTurnAnchorSeed(input){ function createAssistantTurnAnchorSeed(input){
const opts=(input&&typeof input==='object')?input:{}; const opts=(input&&typeof input==='object')?input:{};
const sessionId=_cleanString(opts.session_id); const sessionId=_cleanString(opts.session_id);
@@ -407,6 +564,8 @@
activity_events:[], activity_events:[],
artifacts:[], artifacts:[],
side_effects:[], side_effects:[],
metadata_events:[],
transport_events:[],
usage:null, usage:null,
presentation_state:{ presentation_state:{
compact_worklog:{expanded:false}, compact_worklog:{expanded:false},
@@ -416,8 +575,25 @@
}; };
} }
function createAssistantTurnAnchorRegistry(input){
const anchor=createAssistantTurnAnchorSeed(input);
return {
identity:anchor.identity,
anchor,
event_index:{
dedupe_keys:[],
},
stats:{
applied:0,
skipped_duplicate:0,
skipped_excluded:0,
skipped_mismatched:0,
},
};
}
ROOT.HermesAssistantTurnAnchors=Object.freeze({ ROOT.HermesAssistantTurnAnchors=Object.freeze({
version:'slice2-normalizer', version:'slice3-registry',
activityEventKinds:ACTIVITY_EVENT_KINDS, activityEventKinds:ACTIVITY_EVENT_KINDS,
stateLayers:STATE_LAYERS, stateLayers:STATE_LAYERS,
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION, sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
@@ -427,6 +603,10 @@
classifyAssistantTurnAnchorSourceEvent, classifyAssistantTurnAnchorSourceEvent,
normalizeAssistantTurnAnchorSourceEvent, normalizeAssistantTurnAnchorSourceEvent,
normalizeAssistantTurnAnchorSourceEvents, normalizeAssistantTurnAnchorSourceEvents,
createAssistantTurnAnchorRegistry,
applyAssistantTurnAnchorNormalizedEvent,
applyAssistantTurnAnchorSourceEvent,
applyAssistantTurnAnchorSourceEvents,
isAssistantTurnAnchorActivityKind, isAssistantTurnAnchorActivityKind,
}); });
})(); })();
@@ -1,4 +1,4 @@
"""Slice 2 normalizer tests for Stable Assistant Turn Anchors (#3926).""" """Normalizer tests for Stable Assistant Turn Anchors (#3926)."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -180,7 +180,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
live = data["liveToken"] live = data["liveToken"]
replay = data["replayToken"] replay = data["replayToken"]
assert data["version"] == "slice2-normalizer" assert data["version"] == "slice3-registry"
assert live["classification"] == "activity" assert live["classification"] == "activity"
assert live["dedupe_key"] == 'event_id:"run-1:7"' assert live["dedupe_key"] == 'event_id:"run-1:7"'
assert replay["dedupe_key"] == live["dedupe_key"] assert replay["dedupe_key"] == live["dedupe_key"]
@@ -321,10 +321,13 @@ def test_structured_fallback_dedupe_keys_do_not_collide_on_delimiters():
assert data["localCollisionA"] != data["localCollisionB"] assert data["localCollisionA"] != data["localCollisionB"]
def test_slice2_normalizer_is_still_unwired_from_rendering_hot_paths(): def test_normalizer_and_registry_helpers_are_still_unwired_from_rendering_hot_paths():
helper_names = [ helper_names = [
"normalizeAssistantTurnAnchorSourceEvent", "normalizeAssistantTurnAnchorSourceEvent",
"normalizeAssistantTurnAnchorSourceEvents", "normalizeAssistantTurnAnchorSourceEvents",
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
] ]
for helper in helper_names: for helper in helper_names:
assert helper not in _read(UI_JS) assert helper not in _read(UI_JS)
@@ -0,0 +1,144 @@
"""Slice 3 registry tests for Stable Assistant Turn Anchors (#3926)."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ANCHORS_JS = REPO / "static" / "assistant_turn_anchors.js"
MESSAGES_JS = REPO / "static" / "messages.js"
UI_JS = REPO / "static" / "ui.js"
SESSIONS_JS = REPO / "static" / "sessions.js"
NODE = shutil.which("node")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _registry_snapshot() -> dict:
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
script = f"""
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync({json.dumps(str(ANCHORS_JS))}, 'utf8');
const sandbox = {{window:{{}}}};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
const api = sandbox.window.HermesAssistantTurnAnchors;
const registry = api.createAssistantTurnAnchorRegistry({{
session_id:'sid-1',
turn_id:'turn-1',
}});
const results = api.applyAssistantTurnAnchorSourceEvents(registry, [
{{type:'token', data:'{{"text":"live token"}}', lastEventId:'run-1:1', created_at:'2026-06-11T00:00:01Z'}},
{{event:'token', payload:{{text:'replay token'}}, event_id:'run-1:1', seq:1}},
{{event:'reasoning', payload:{{text:'thinking'}}, event_id:'run-1:2', seq:2}},
{{event:'artifact_reference', payload:{{path:'answer.txt', kind:'workspace_file'}}, event_id:'run-1:3', seq:3}},
{{event:'state_saved', payload:{{kind:'memory', name:'session-state'}}, event_id:'run-1:4', seq:4}},
{{event:'stream_end', payload:{{}}, event_id:'run-1:5', seq:5}},
{{event:'done', payload:{{}}, event_id:'run-1:6', seq:6, created_at:'2026-06-11T00:00:06Z'}},
{{source_type:'settled_message', payload:{{role:'assistant', id:'message-final', content:'final answer', _turnUsage:{{input_tokens:8, output_tokens:13}}}}}},
], {{run_id:'run-1', stream_id:'stream-1'}});
const isolated = api.createAssistantTurnAnchorRegistry({{session_id:'sid-1', turn_id:'turn-2'}});
api.applyAssistantTurnAnchorSourceEvent(registry, {{
event:'token',
payload:{{text:'wrong session', session_id:'sid-2'}},
event_id:'run-1:7',
seq:7,
}}, {{run_id:'run-1'}});
console.log(JSON.stringify({{
version:api.version,
registry,
isolated,
results:results.map((item)=>({{applied:item.applied, reason:item.reason}})),
}}));
"""
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_registry_owns_one_anchor_and_dedupes_live_plus_replay_events():
data = _registry_snapshot()
registry = data["registry"]
anchor = registry["anchor"]
assert data["version"] == "slice3-registry"
assert [item["reason"] for item in data["results"][:2]] == [None, "duplicate"]
assert registry["event_index"]["dedupe_keys"][:2] == [
"event_id:run-1:1",
"event_id:run-1:2",
]
assert registry["stats"]["applied"] == 7
assert registry["stats"]["skipped_duplicate"] == 1
assert registry["stats"]["skipped_mismatched"] == 1
assert anchor["identity"]["session_id"] == "sid-1"
assert anchor["identity"]["turn_id"] == "turn-1"
assert anchor["identity"]["run_id"] == "run-1"
assert anchor["identity"]["stream_id"] == "stream-1"
assert [event["kind"] for event in anchor["activity_events"]] == [
"process_prose",
"reasoning",
"terminal_status",
]
assert anchor["activity_events"][0]["payload"] == {"text": "live token"}
def test_registry_routes_activity_artifacts_side_effects_metadata_and_transport():
data = _registry_snapshot()
anchor = data["registry"]["anchor"]
assert len(anchor["artifacts"]) == 1
assert anchor["artifacts"][0]["source_event_type"] == "artifact_reference"
assert anchor["artifacts"][0]["payload"] == {
"kind": "workspace_file",
"path": "answer.txt",
}
assert len(anchor["side_effects"]) == 1
assert anchor["side_effects"][0]["source_event_type"] == "state_saved"
assert len(anchor["metadata_events"]) == 1
assert anchor["metadata_events"][0]["source_event_type"] == "settled_message"
assert len(anchor["transport_events"]) == 1
assert anchor["transport_events"][0]["source_event_type"] == "stream_end"
def test_registry_updates_lifecycle_and_settled_final_projection():
data = _registry_snapshot()
anchor = data["registry"]["anchor"]
assert anchor["lifecycle"]["status"] == "completed"
assert anchor["lifecycle"]["terminal_state"] == "completed"
assert anchor["lifecycle"]["started_at"] == "2026-06-11T00:00:01Z"
assert anchor["lifecycle"]["completed_at"] == "2026-06-11T00:00:06Z"
assert anchor["content"]["final_answer"] == "final answer"
assert anchor["content"]["final_message_ref"] == "message-final"
assert anchor["usage"] == {"input_tokens": 8, "output_tokens": 13}
def test_registry_instances_do_not_share_owner_state():
data = _registry_snapshot()
isolated = data["isolated"]
assert isolated["identity"]["turn_id"] == "turn-2"
assert isolated["event_index"]["dedupe_keys"] == []
assert isolated["stats"]["applied"] == 0
assert isolated["anchor"]["activity_events"] == []
def test_slice3_registry_is_still_unwired_from_rendering_hot_paths():
helper_names = [
"createAssistantTurnAnchorRegistry",
"applyAssistantTurnAnchorNormalizedEvent",
"applyAssistantTurnAnchorSourceEvent",
"applyAssistantTurnAnchorSourceEvents",
]
for helper in helper_names:
assert helper not in _read(UI_JS)
assert helper not in _read(SESSIONS_JS)
assert helper not in _read(MESSAGES_JS)