diff --git a/.planning/adr/0056-ui-route-session-context.md b/.planning/adr/0056-ui-route-session-context.md new file mode 100644 index 00000000..9ce3bbd1 --- /dev/null +++ b/.planning/adr/0056-ui-route-session-context.md @@ -0,0 +1,50 @@ +# ADR 0056 — Host SESSION CONTEXT extras via visitor.data + +**Status**: Accepted +**Date**: 2026-09-24 +**Relation**: Extends [ADR-0042](0042-session-context-ground-truth.md) and +[ADR-0049](0049-session-context-placement-and-cache-telemetry.md). Aligns with +[thin-harness](../../docs/thin-harness.md) invariant 3 and invariant 8 +(foundation stays domain-agnostic). + +--- + +## 1. Context + +Hosts often need turn-stable **environment** facts beyond clock and channel +(e.g. where the user is in a product UI). Those facts already travel on +``visitor.data``, which is intentionally flexible — but **nothing in the +Orchestrator dumps that dict into the prompt**. Hosts were forced either to +prepend prose onto the user utterance (wrong channel; topic pollution) or to +ask the framework to parse a host-specific schema. + +## 2. Decision + +``render_session_context`` appends an optional host block when +``visitor.data["session_context_extra"]`` is a non-empty string (or a list of +strings joined with newlines). The harness: + +- Trims and length-caps the text +- Does **not** inspect keys, ids, or product nouns inside it +- Places it inside SESSION CONTEXT (same authority class as clock/channel) + +Hosts own formatting and policy wording (e.g. “optional focus — not default +answer scope”). Rich snapshots for tools stay on host-chosen keys +(``page_context``, etc.) and are **not** read by this path. + +## 3. Consequences + +- Framework stays schema-free; Integral (or any embedder) maps domain → prose +- Utterance stays clean; route awareness can live in the system prompt +- A buggy host can still inject up to the cap — treat as trusted host process + (same trust as other ``visitor.data`` fields) + +## 4. Alternatives considered + +- Parse Integral ``page_context`` inside jvagent — rejected (domain bleed; + collides with messenger ``page_context`` shape) +- Tool-only awareness — rejected for always-on deixis cost +- Host InteractAction only — workable with zero core change, but SESSION + CONTEXT placement (ADR-0049) is the right channel for env facts; a one-key + append is thinner than another InteractAction for every host +- Auto-dump entire ``visitor.data`` — rejected (PII / bloat / not facts) diff --git a/CHANGELOG.md b/CHANGELOG.md index d647b168..8630fa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ### Added +- **Host SESSION CONTEXT extras (ADR-0056).** ``visitor.data["session_context_extra"]`` + (string or list of strings) is appended verbatim into SESSION CONTEXT after + clock/channel. Harness does not parse host schemas — hosts render their own + UI-route (or other env) prose. Caps length; empty omitted. + - **Unified capability discovery (`find_capability`, ADR-0055).** Primary lean discovery meta-tool ranks matching **skills** then **tools** in one observation, with `use_skill` / `load_tool` next-step cues. `find_tool` / `find_skill` remain aliases. Loop protocol, lean partial-list hint, and unknown-tool bounce steer to `find_capability` first so domain SOPs activate instead of find_tool thrash. - **Harness excellence runtime (HP-02 … HP-12).** `jvagent.harness.runtime` is the store-backed source of truth for NativeCaller admission, snapshot-keyed caches, TurnRun journals, invocation ledger, durable outbox, session leases, host providers, skill manifests/isolation, traces, and the HP-12 deployment matrix. Process-local bus/caches remain fan-out; JSON/SQLite active-active is unsupported. Docs: `docs/HARNESS_DEPLOYMENT.md`, `docs/skill-isolation.md`. TurnRun checkpoints persist on `Interaction.observability_metrics`; loop resume skips completed IDEMPOTENT invocations; Claude skill staging is snapshot/digest-keyed and refuses untrusted isolation; mutating send/delete/bash tools declare `NON_RETRYABLE`; embed cancel marks TurnRun recovery. HostCapabilityProvider.invoke dispatches a registered host runner; IsolatedExecutor wraps approved backends with no subprocess fallback; dump_store/load_store persist the harness store; file/redis/dynamo lease adapters require an explicit client; skill signatures use HMAC compare_digest; CUCS harness evals live under `tests/conformance/cucs/`; CI adds conformance/two-worker/isolation/load lanes. diff --git a/docs/ORCHESTRATOR.md b/docs/ORCHESTRATOR.md index ec6450d3..18c213e1 100644 --- a/docs/ORCHESTRATOR.md +++ b/docs/ORCHESTRATOR.md @@ -37,7 +37,7 @@ The orchestrator and every action on its tool surface follow the **[thin harness **Admission snapshot (ADR-0054).** Target contract: one immutable `ToolSurfaceSnapshot` per turn, keyed by `snapshot_id` + `(agent_id, user_id, session_id)`. Lean discovery (`find_capability` primary; `find_tool` / `load_tool` / `find_skill` / `use_skill`) stays. Host tools/skills enter only through `HostCapabilityProvider`, never via Orchestrator imports. Types: [`jvagent/harness/contracts.py`](../jvagent/harness/contracts.py). Runtime snapshot cache is HP-03; today's tool surface cache is still per-agent ([`catalog.py`](../jvagent/action/orchestrator/catalog.py)). -**SESSION CONTEXT** ([ADR-0042](../.planning/adr/0042-session-context-ground-truth.md)) is turn-stable environment ground truth (current date/time via `App.now()`, channel), injected into the system prompt each turn — the same class as the former CURRENT CHANNEL line. It is **not** prep steering: relative time must use that clock; `get_current_datetime` remains for mid-turn refresh only. +**SESSION CONTEXT** ([ADR-0042](../.planning/adr/0042-session-context-ground-truth.md), [ADR-0056](../.planning/adr/0056-ui-route-session-context.md)) is turn-stable environment ground truth (current date/time via `App.now()`, channel, optional host prose from `visitor.data["session_context_extra"]`), injected into the system prompt each turn — the same class as the former CURRENT CHANNEL line. It is **not** prep steering: relative time must use that clock; `get_current_datetime` remains for mid-turn refresh only. Hosts format any UI-route facts themselves; the harness does not parse product schemas from `visitor.data`. Subsystem-specific rules (e.g. interviews) extend the platform doc as **profiles** — see [Interview profile](../jvagent/action/interview/docs/thin-harness.md). diff --git a/jvagent/action/orchestrator/session_context.py b/jvagent/action/orchestrator/session_context.py index 9c1760e9..eb13756f 100644 --- a/jvagent/action/orchestrator/session_context.py +++ b/jvagent/action/orchestrator/session_context.py @@ -1,4 +1,4 @@ -"""Turn-stable session ground truth for the Orchestrator (ADR-0042). +"""Turn-stable session ground truth for the Orchestrator (ADR-0042 / ADR-0056). Injected once per turn into the system prompt — same class of environment facts as the former CURRENT CHANNEL line. Not prep steering: the model still @@ -8,7 +8,35 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Any +from typing import Any, List, Optional + +# Hosts may put a pre-rendered prose block on visitor.data under this key. +# Harness appends it verbatim to SESSION CONTEXT — no schema inspection +# (ADR-0056). Cap so a buggy host cannot blow the system prompt. +SESSION_CONTEXT_EXTRA_KEY = "session_context_extra" +_MAX_SESSION_CONTEXT_EXTRA_CHARS = 2000 + + +def normalize_session_context_extra(raw: Any) -> Optional[str]: + """Return a trimmed host extra block, or None if empty / unusable.""" + if raw is None: + return None + if isinstance(raw, str): + text = raw.strip() + elif isinstance(raw, (list, tuple)): + parts: List[str] = [] + for item in raw: + piece = str(item or "").strip() + if piece: + parts.append(piece) + text = "\n".join(parts).strip() + else: + return None + if not text: + return None + if len(text) > _MAX_SESSION_CONTEXT_EXTRA_CHARS: + text = text[: _MAX_SESSION_CONTEXT_EXTRA_CHARS - 1] + "…" + return text async def render_session_context( @@ -20,6 +48,7 @@ async def render_session_context( Uses ``App.now()`` when an app is available; otherwise UTC wall clock. Channel is included when ``visitor.channel`` is set. + Optional host prose via ``visitor.data["session_context_extra"]`` (ADR-0056). """ now = await _resolve_now(app) tz = getattr(now.tzinfo, "key", None) or ( @@ -44,6 +73,11 @@ async def render_session_context( 'Relative time ("today", "this year", "yesterday", etc.) MUST use ' "this clock — never a training cutoff or a guessed year." ) + data = getattr(visitor, "data", None) or {} + if isinstance(data, dict): + extra = normalize_session_context_extra(data.get(SESSION_CONTEXT_EXTRA_KEY)) + if extra: + lines.append(extra) return "\n".join(lines) + "\n\n" diff --git a/tests/action/orchestrator/test_session_context.py b/tests/action/orchestrator/test_session_context.py index 0587c14e..681ac63d 100644 --- a/tests/action/orchestrator/test_session_context.py +++ b/tests/action/orchestrator/test_session_context.py @@ -25,24 +25,67 @@ async def now(self, fmt=None): @pytest.mark.asyncio async def test_render_session_context_includes_frozen_clock_and_channel(): now = datetime(2026, 7, 27, 13, 45, 0, tzinfo=timezone.utc) - visitor = SimpleNamespace(channel="web") + visitor = SimpleNamespace(channel="web", data={}) text = await render_session_context(visitor, app=_FakeApp(now)) assert "SESSION CONTEXT" in text assert "2026" in text assert "ISO 8601: 2026-07-27T13:45:00+00:00" in text assert "CURRENT CHANNEL: web" in text assert "training cutoff" in text.lower() or "guessed year" in text + assert "UI ROUTE" not in text @pytest.mark.asyncio async def test_render_session_context_omits_channel_when_empty(): now = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) - visitor = SimpleNamespace(channel="") + visitor = SimpleNamespace(channel="", data={}) text = await render_session_context(visitor, app=_FakeApp(now)) assert "CURRENT CHANNEL" not in text assert "2026" in text +@pytest.mark.asyncio +async def test_render_session_context_appends_host_extra_verbatim(): + from jvagent.action.orchestrator.session_context import ( + SESSION_CONTEXT_EXTRA_KEY, + normalize_session_context_extra, + ) + + now = datetime(2026, 9, 24, 12, 0, 0, tzinfo=timezone.utc) + extra = ( + "UI ROUTE (optional focus — not default answer scope):\n" + ' kind=app_dashboards · app="Sales" · app_id=n.WorkspaceApp.abc\n' + " path=/apps/n.WorkspaceApp.abc" + ) + visitor = SimpleNamespace( + channel="integral", + data={SESSION_CONTEXT_EXTRA_KEY: extra, "page_context": {"ignored": True}}, + ) + text = await render_session_context(visitor, app=_FakeApp(now)) + assert extra in text + assert "ignored" not in text + assert normalize_session_context_extra(" hi ") == "hi" + assert normalize_session_context_extra(["a", "", "b"]) == "a\nb" + assert normalize_session_context_extra("") is None + assert normalize_session_context_extra({"x": 1}) is None + + +@pytest.mark.asyncio +async def test_session_context_extra_is_capped(): + from jvagent.action.orchestrator.session_context import ( + SESSION_CONTEXT_EXTRA_KEY, + normalize_session_context_extra, + ) + + huge = "x" * 5000 + assert len(normalize_session_context_extra(huge) or "") <= 2000 + now = datetime(2026, 9, 24, 12, 0, 0, tzinfo=timezone.utc) + visitor = SimpleNamespace(channel="web", data={SESSION_CONTEXT_EXTRA_KEY: huge}) + text = await render_session_context(visitor, app=_FakeApp(now)) + assert text.count("x") <= 2000 + assert text.rstrip().endswith("…") or "x…" in text or text.endswith("…\n\n") + + @pytest.mark.asyncio async def test_compose_places_session_context_last(): """ADR-0049: the per-turn block sits AFTER the stable sections so the