feat(anchor): add inert turn registry skeleton
This commit is contained in:
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
|
||||
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
|
||||
|
||||
Phase 0 classifies current sources before changing render behavior:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Stable Assistant Turn Anchors scaffold (#3926).
|
||||
//
|
||||
// This file is intentionally inert: it defines the current ownership inventory,
|
||||
// event classifications, and small pure helpers, but it does not register
|
||||
// anchors or change any renderer. Later phases can wire these helpers into
|
||||
// event classifications, and small owner helpers, but it does not register
|
||||
// anchors globally or change any renderer. Later phases can wire these helpers into
|
||||
// send(), attachLiveStream(), replay hydration, and renderMessages().
|
||||
(function(){
|
||||
const ROOT=(typeof window!=='undefined')?window:globalThis;
|
||||
@@ -374,6 +374,163 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function _copyObject(value){
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)) return {};
|
||||
return {...value};
|
||||
}
|
||||
|
||||
function _registryAnchor(registry){
|
||||
return registry&&typeof registry==='object'&®istry.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&®istry.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){
|
||||
const opts=(input&&typeof input==='object')?input:{};
|
||||
const sessionId=_cleanString(opts.session_id);
|
||||
@@ -407,6 +564,8 @@
|
||||
activity_events:[],
|
||||
artifacts:[],
|
||||
side_effects:[],
|
||||
metadata_events:[],
|
||||
transport_events:[],
|
||||
usage:null,
|
||||
presentation_state:{
|
||||
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({
|
||||
version:'slice2-normalizer',
|
||||
version:'slice3-registry',
|
||||
activityEventKinds:ACTIVITY_EVENT_KINDS,
|
||||
stateLayers:STATE_LAYERS,
|
||||
sourceEventClassification:SOURCE_EVENT_CLASSIFICATION,
|
||||
@@ -427,6 +603,10 @@
|
||||
classifyAssistantTurnAnchorSourceEvent,
|
||||
normalizeAssistantTurnAnchorSourceEvent,
|
||||
normalizeAssistantTurnAnchorSourceEvents,
|
||||
createAssistantTurnAnchorRegistry,
|
||||
applyAssistantTurnAnchorNormalizedEvent,
|
||||
applyAssistantTurnAnchorSourceEvent,
|
||||
applyAssistantTurnAnchorSourceEvents,
|
||||
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
|
||||
|
||||
import json
|
||||
@@ -180,7 +180,7 @@ def test_normalizer_maps_live_and_replay_to_same_anchor_event_identity():
|
||||
live = data["liveToken"]
|
||||
replay = data["replayToken"]
|
||||
|
||||
assert data["version"] == "slice2-normalizer"
|
||||
assert data["version"] == "slice3-registry"
|
||||
assert live["classification"] == "activity"
|
||||
assert live["dedupe_key"] == 'event_id:"run-1:7"'
|
||||
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"]
|
||||
|
||||
|
||||
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 = [
|
||||
"normalizeAssistantTurnAnchorSourceEvent",
|
||||
"normalizeAssistantTurnAnchorSourceEvents",
|
||||
"createAssistantTurnAnchorRegistry",
|
||||
"applyAssistantTurnAnchorSourceEvent",
|
||||
"applyAssistantTurnAnchorSourceEvents",
|
||||
]
|
||||
for helper in helper_names:
|
||||
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)
|
||||
Reference in new issue
Block a user