From 1a2f257cb3ef895d5c7ca22ca3f99c61c25943b7 Mon Sep 17 00:00:00 2001 From: Eldon Marks Date: Thu, 24 Sep 2026 20:41:39 -0400 Subject: [PATCH 1/2] feat(orchestrator): UI ROUTE in SESSION CONTEXT (ADR-0056) Render compact host page_context facts in the system prompt so route awareness is environment ground truth, not an utterance preamble. --- .../adr/0056-ui-route-session-context.md | 55 ++++++++ CHANGELOG.md | 6 + docs/ORCHESTRATOR.md | 2 +- .../action/orchestrator/session_context.py | 130 +++++++++++++++++- .../orchestrator/test_session_context.py | 49 ++++++- 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 .planning/adr/0056-ui-route-session-context.md 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..0be387e7 --- /dev/null +++ b/.planning/adr/0056-ui-route-session-context.md @@ -0,0 +1,55 @@ +# ADR 0056 — UI ROUTE in SESSION CONTEXT + +**Status**: Accepted +**Date**: 2026-09-24 +**Relation**: Extends [ADR-0042](0042-session-context-ground-truth.md) (SESSION +CONTEXT ground truth) and [ADR-0049](0049-session-context-placement-and-cache-telemetry.md) +(placement). Aligns with [thin-harness](../../docs/thin-harness.md) invariant 3 +(environment facts ≠ prep steering). + +--- + +## 1. Context + +Hosts such as Integral already place a UI snapshot on +``visitor.data["page_context"]`` (path, page kind, breadcrumbs, focused +resource ids, display titles). Integral previously **prepended** a delimited +prose stub onto the **user utterance** so the model could see the route. That +channel treats on-screen Apps as *topic*, bloats the transcript, and invites +overfitting (e.g. answering personal-expense questions from a Sales board). + +SESSION CONTEXT already carries turn-stable environment facts (clock, channel). +UI location is the same class of fact. + +## 2. Decision + +When ``visitor.data`` contains a usable ``page_context`` dict, +``render_session_context`` appends a compact **UI ROUTE** block inside +SESSION CONTEXT: + +- Dense lines: ``kind``, human labels + ids for focused app/track/view/entry + (and optional ``dashboard_id`` from metadata), ``path``, ``crumbs`` +- Fixed authority line: focused ids are **optional** — apply only for + this/here/crumb match or a clear resource match; do not answer from the + focused resource merely because it is on screen +- **No** visible entry/track lists (hosts expose those via a page-context tool) +- **No** tool names or next-step cues (thin-harness) +- Messenger-style ``title`` + ``path`` remains a thin fallback + +Hosts that previously injected utterance preambles should stop; the snapshot +on ``visitor.data`` is enough. + +## 3. Consequences + +- Route awareness survives without polluting the user message +- Always-on cost stays small (~3–5 lines) — no soft/minimal utterance gate +- Integral (and similar hosts) keep a tool for on-screen lists / full snapshot +- Prompt-cache prefix above SESSION CONTEXT unchanged (ADR-0049) + +## 4. Alternatives considered + +- Utterance prepend with HTML delimiters — rejected (topic pollution) +- Tool-only awareness — rejected (extra tick for every deixis) +- Reuse messenger ``PageContextInteractAction`` — rejected (wrong schema; + response parameters, not system ground truth) +- Soft/minimal utterance gate — transitional only; superseded by this ADR diff --git a/CHANGELOG.md b/CHANGELOG.md index d647b168..c7d445c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ### Added +- **UI ROUTE in SESSION CONTEXT (ADR-0056).** When ``visitor.data["page_context"]`` + carries a host UI snapshot (Integral shape or messenger title/path), + ``render_session_context`` appends a compact optional-focus block — kind, + labels/ids, path, crumbs — so route awareness lives with clock/channel + instead of an utterance preamble. Facts only; no tool cues. + - **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..7c529c7e 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 **UI ROUTE** from `visitor.data["page_context"]`), 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. Focused UI ids are optional background, not default answer scope. 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..c28a92ed 100644 --- a/jvagent/action/orchestrator/session_context.py +++ b/jvagent/action/orchestrator/session_context.py @@ -1,14 +1,132 @@ -"""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 -decides tools; this only removes the need to guess the clock. +decides tools; this only removes the need to guess the clock or invent where +the user is in a host UI. """ from __future__ import annotations from datetime import datetime, timezone -from typing import Any +from typing import Any, Dict, List, Optional + + +_MAX_PATH_CHARS = 200 +_MAX_CRUMB_CHARS = 160 +_MAX_LABEL_CHARS = 80 + + +def format_ui_route(ctx: Any) -> Optional[str]: + """Render host UI route facts for SESSION CONTEXT (ADR-0056). + + Accepts the Integral ``page_context`` snapshot shape (``page_kind``, + ``breadcrumbs``, ``focused_*``, ``metadata`` titles) and, as a thin + fallback, messenger-style ``title`` + ``path``. Returns ``None`` when + nothing useful is present — callers omit the block entirely. + + Facts only: no tool names, no next-step cues (thin-harness invariant 3). + """ + if not isinstance(ctx, dict) or not ctx: + return None + + kind = _clip(str(ctx.get("page_kind") or "").strip(), _MAX_LABEL_CHARS) + path = _clip( + str(ctx.get("route_path") or ctx.get("url") or ctx.get("path") or "").strip(), + _MAX_PATH_CHARS, + ) + crumbs = _format_crumbs(ctx.get("breadcrumbs")) + meta = ctx.get("metadata") if isinstance(ctx.get("metadata"), dict) else {} + + focus_bits: List[str] = [] + app_label = _meta_label(meta, ("app_title", "app_name", "app")) + track_label = _meta_label(meta, ("track_title", "track_name", "track")) + entry_label = _meta_label(meta, ("entry_title", "entry_name", "entry")) + if app_label: + focus_bits.append(f'app="{app_label}"') + app_id = _clip(str(ctx.get("focused_app_id") or "").strip(), 128) + if app_id: + focus_bits.append(f"app_id={app_id}") + if track_label: + focus_bits.append(f'track="{track_label}"') + track_id = _clip(str(ctx.get("focused_track_id") or "").strip(), 128) + if track_id: + focus_bits.append(f"track_id={track_id}") + view_id = _clip(str(ctx.get("focused_view_id") or "").strip(), 128) + if view_id: + focus_bits.append(f"view_id={view_id}") + if entry_label: + focus_bits.append(f'entry="{entry_label}"') + entry_id = _clip(str(ctx.get("focused_entry_id") or "").strip(), 128) + if entry_id: + focus_bits.append(f"entry_id={entry_id}") + dash_id = _clip(str(meta.get("focused_dashboard_id") or "").strip(), 128) + if dash_id: + focus_bits.append(f"dashboard_id={dash_id}") + + # Messenger embed fallback (title/path only). + messenger_title = _clip(str(ctx.get("title") or "").strip(), _MAX_LABEL_CHARS) + + summary_bits: List[str] = [] + if kind: + summary_bits.append(f"kind={kind}") + summary_bits.extend(focus_bits) + if not summary_bits and messenger_title: + summary_bits.append(f'title="{messenger_title}"') + if not summary_bits and not path and not crumbs: + return None + + lines = [ + "UI ROUTE (optional focus — not default answer scope):", + ] + if summary_bits: + lines.append(" " + " · ".join(summary_bits)) + if path: + lines.append(f" path={path}") + if crumbs: + lines.append(f" crumbs={crumbs}") + lines.append( + " Apply focused ids only when the user refers to the current screen " + "(this/here/crumb name) or the ask clearly matches that resource; " + "otherwise search the host workspace — do not answer from the focused " + "resource merely because it is on screen." + ) + return "\n".join(lines) + + +def _clip(text: str, limit: int) -> str: + if len(text) <= limit: + return text + if limit <= 1: + return text[:limit] + return text[: limit - 1] + "…" + + +def _meta_label(meta: Dict[str, Any], keys: tuple) -> str: + for key in keys: + raw = meta.get(key) + if raw is None: + continue + text = _clip(str(raw).strip(), _MAX_LABEL_CHARS) + if text: + return text + return "" + + +def _format_crumbs(raw: Any) -> str: + if not isinstance(raw, list) or not raw: + return "" + labels: List[str] = [] + for item in raw[:12]: + if isinstance(item, dict): + label = str(item.get("label") or "").strip() + else: + label = str(item or "").strip() + if label: + labels.append(_clip(label, 40)) + if not labels: + return "" + return _clip(" › ".join(labels), _MAX_CRUMB_CHARS) async def render_session_context( @@ -20,6 +138,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. + Host UI route (ADR-0056) when ``visitor.data["page_context"]`` is set. """ now = await _resolve_now(app) tz = getattr(now.tzinfo, "key", None) or ( @@ -44,6 +163,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): + ui_route = format_ui_route(data.get("page_context")) + if ui_route: + lines.append(ui_route) 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..963edb76 100644 --- a/tests/action/orchestrator/test_session_context.py +++ b/tests/action/orchestrator/test_session_context.py @@ -25,24 +25,69 @@ 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_includes_ui_route_from_page_context(): + from jvagent.action.orchestrator.session_context import format_ui_route + + now = datetime(2026, 9, 24, 12, 0, 0, tzinfo=timezone.utc) + page = { + "url": "/apps/n.WorkspaceApp.abc", + "route_path": "/apps/n.WorkspaceApp.abc", + "page_kind": "app_dashboards", + "breadcrumbs": [ + {"label": "Business Admin"}, + {"label": "Apps"}, + {"label": "Sales"}, + ], + "focused_app_id": "n.WorkspaceApp.abc", + "metadata": {"app_name": "Sales", "focused_dashboard_id": "n.Dashboard.1"}, + } + visitor = SimpleNamespace(channel="integral", data={"page_context": page}) + text = await render_session_context(visitor, app=_FakeApp(now)) + assert "UI ROUTE (optional focus — not default answer scope):" in text + assert 'app="Sales"' in text + assert "app_id=n.WorkspaceApp.abc" in text + assert "kind=app_dashboards" in text + assert "crumbs=Business Admin › Apps › Sales" in text + assert "dashboard_id=n.Dashboard.1" in text + assert "merely because it is on screen" in text + # Compact block is also unit-testable alone. + block = format_ui_route(page) + assert block is not None + assert "Visible" not in block + + +@pytest.mark.asyncio +async def test_format_ui_route_messenger_fallback(): + from jvagent.action.orchestrator.session_context import format_ui_route + + block = format_ui_route({"title": "Pricing", "path": "/pricing"}) + assert block is not None + assert 'title="Pricing"' in block + assert "path=/pricing" in block + assert format_ui_route({}) is None + assert format_ui_route(None) is None + + @pytest.mark.asyncio async def test_compose_places_session_context_last(): """ADR-0049: the per-turn block sits AFTER the stable sections so the From eea30218ba628a3635d32caa1aa2026883338861 Mon Sep 17 00:00:00 2001 From: Eldon Marks Date: Thu, 24 Sep 2026 20:50:40 -0400 Subject: [PATCH 2/2] refactor(orchestrator): schema-free session_context_extra (ADR-0056) Hosts append pre-rendered SESSION CONTEXT prose via visitor.data; harness no longer parses page_context product fields. --- .../adr/0056-ui-route-session-context.md | 67 ++++---- CHANGELOG.md | 9 +- docs/ORCHESTRATOR.md | 2 +- .../action/orchestrator/session_context.py | 148 ++++-------------- .../orchestrator/test_session_context.py | 68 ++++---- 5 files changed, 98 insertions(+), 196 deletions(-) diff --git a/.planning/adr/0056-ui-route-session-context.md b/.planning/adr/0056-ui-route-session-context.md index 0be387e7..9ce3bbd1 100644 --- a/.planning/adr/0056-ui-route-session-context.md +++ b/.planning/adr/0056-ui-route-session-context.md @@ -1,55 +1,50 @@ -# ADR 0056 — UI ROUTE in SESSION CONTEXT +# 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) (SESSION -CONTEXT ground truth) and [ADR-0049](0049-session-context-placement-and-cache-telemetry.md) -(placement). Aligns with [thin-harness](../../docs/thin-harness.md) invariant 3 -(environment facts ≠ prep steering). +**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 such as Integral already place a UI snapshot on -``visitor.data["page_context"]`` (path, page kind, breadcrumbs, focused -resource ids, display titles). Integral previously **prepended** a delimited -prose stub onto the **user utterance** so the model could see the route. That -channel treats on-screen Apps as *topic*, bloats the transcript, and invites -overfitting (e.g. answering personal-expense questions from a Sales board). - -SESSION CONTEXT already carries turn-stable environment facts (clock, channel). -UI location is the same class of fact. +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 -When ``visitor.data`` contains a usable ``page_context`` dict, -``render_session_context`` appends a compact **UI ROUTE** block inside -SESSION CONTEXT: +``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: -- Dense lines: ``kind``, human labels + ids for focused app/track/view/entry - (and optional ``dashboard_id`` from metadata), ``path``, ``crumbs`` -- Fixed authority line: focused ids are **optional** — apply only for - this/here/crumb match or a clear resource match; do not answer from the - focused resource merely because it is on screen -- **No** visible entry/track lists (hosts expose those via a page-context tool) -- **No** tool names or next-step cues (thin-harness) -- Messenger-style ``title`` + ``path`` remains a thin fallback +- 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 that previously injected utterance preambles should stop; the snapshot -on ``visitor.data`` is enough. +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 -- Route awareness survives without polluting the user message -- Always-on cost stays small (~3–5 lines) — no soft/minimal utterance gate -- Integral (and similar hosts) keep a tool for on-screen lists / full snapshot -- Prompt-cache prefix above SESSION CONTEXT unchanged (ADR-0049) +- 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 -- Utterance prepend with HTML delimiters — rejected (topic pollution) -- Tool-only awareness — rejected (extra tick for every deixis) -- Reuse messenger ``PageContextInteractAction`` — rejected (wrong schema; - response parameters, not system ground truth) -- Soft/minimal utterance gate — transitional only; superseded by this ADR +- 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 c7d445c8..8630fa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,10 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) / ### Added -- **UI ROUTE in SESSION CONTEXT (ADR-0056).** When ``visitor.data["page_context"]`` - carries a host UI snapshot (Integral shape or messenger title/path), - ``render_session_context`` appends a compact optional-focus block — kind, - labels/ids, path, crumbs — so route awareness lives with clock/channel - instead of an utterance preamble. Facts only; no tool cues. +- **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. diff --git a/docs/ORCHESTRATOR.md b/docs/ORCHESTRATOR.md index 7c529c7e..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), [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 **UI ROUTE** from `visitor.data["page_context"]`), 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. Focused UI ids are optional background, not default answer scope. +**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 c28a92ed..eb13756f 100644 --- a/jvagent/action/orchestrator/session_context.py +++ b/jvagent/action/orchestrator/session_context.py @@ -2,131 +2,41 @@ 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 -decides tools; this only removes the need to guess the clock or invent where -the user is in a host UI. +decides tools; this only removes the need to guess the clock. """ from __future__ import annotations from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +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 -_MAX_PATH_CHARS = 200 -_MAX_CRUMB_CHARS = 160 -_MAX_LABEL_CHARS = 80 - -def format_ui_route(ctx: Any) -> Optional[str]: - """Render host UI route facts for SESSION CONTEXT (ADR-0056). - - Accepts the Integral ``page_context`` snapshot shape (``page_kind``, - ``breadcrumbs``, ``focused_*``, ``metadata`` titles) and, as a thin - fallback, messenger-style ``title`` + ``path``. Returns ``None`` when - nothing useful is present — callers omit the block entirely. - - Facts only: no tool names, no next-step cues (thin-harness invariant 3). - """ - if not isinstance(ctx, dict) or not ctx: +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 - - kind = _clip(str(ctx.get("page_kind") or "").strip(), _MAX_LABEL_CHARS) - path = _clip( - str(ctx.get("route_path") or ctx.get("url") or ctx.get("path") or "").strip(), - _MAX_PATH_CHARS, - ) - crumbs = _format_crumbs(ctx.get("breadcrumbs")) - meta = ctx.get("metadata") if isinstance(ctx.get("metadata"), dict) else {} - - focus_bits: List[str] = [] - app_label = _meta_label(meta, ("app_title", "app_name", "app")) - track_label = _meta_label(meta, ("track_title", "track_name", "track")) - entry_label = _meta_label(meta, ("entry_title", "entry_name", "entry")) - if app_label: - focus_bits.append(f'app="{app_label}"') - app_id = _clip(str(ctx.get("focused_app_id") or "").strip(), 128) - if app_id: - focus_bits.append(f"app_id={app_id}") - if track_label: - focus_bits.append(f'track="{track_label}"') - track_id = _clip(str(ctx.get("focused_track_id") or "").strip(), 128) - if track_id: - focus_bits.append(f"track_id={track_id}") - view_id = _clip(str(ctx.get("focused_view_id") or "").strip(), 128) - if view_id: - focus_bits.append(f"view_id={view_id}") - if entry_label: - focus_bits.append(f'entry="{entry_label}"') - entry_id = _clip(str(ctx.get("focused_entry_id") or "").strip(), 128) - if entry_id: - focus_bits.append(f"entry_id={entry_id}") - dash_id = _clip(str(meta.get("focused_dashboard_id") or "").strip(), 128) - if dash_id: - focus_bits.append(f"dashboard_id={dash_id}") - - # Messenger embed fallback (title/path only). - messenger_title = _clip(str(ctx.get("title") or "").strip(), _MAX_LABEL_CHARS) - - summary_bits: List[str] = [] - if kind: - summary_bits.append(f"kind={kind}") - summary_bits.extend(focus_bits) - if not summary_bits and messenger_title: - summary_bits.append(f'title="{messenger_title}"') - if not summary_bits and not path and not crumbs: + 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 - - lines = [ - "UI ROUTE (optional focus — not default answer scope):", - ] - if summary_bits: - lines.append(" " + " · ".join(summary_bits)) - if path: - lines.append(f" path={path}") - if crumbs: - lines.append(f" crumbs={crumbs}") - lines.append( - " Apply focused ids only when the user refers to the current screen " - "(this/here/crumb name) or the ask clearly matches that resource; " - "otherwise search the host workspace — do not answer from the focused " - "resource merely because it is on screen." - ) - return "\n".join(lines) - - -def _clip(text: str, limit: int) -> str: - if len(text) <= limit: - return text - if limit <= 1: - return text[:limit] - return text[: limit - 1] + "…" - - -def _meta_label(meta: Dict[str, Any], keys: tuple) -> str: - for key in keys: - raw = meta.get(key) - if raw is None: - continue - text = _clip(str(raw).strip(), _MAX_LABEL_CHARS) - if text: - return text - return "" - - -def _format_crumbs(raw: Any) -> str: - if not isinstance(raw, list) or not raw: - return "" - labels: List[str] = [] - for item in raw[:12]: - if isinstance(item, dict): - label = str(item.get("label") or "").strip() - else: - label = str(item or "").strip() - if label: - labels.append(_clip(label, 40)) - if not labels: - return "" - return _clip(" › ".join(labels), _MAX_CRUMB_CHARS) + 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( @@ -138,7 +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. - Host UI route (ADR-0056) when ``visitor.data["page_context"]`` 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 ( @@ -165,9 +75,9 @@ async def render_session_context( ) data = getattr(visitor, "data", None) or {} if isinstance(data, dict): - ui_route = format_ui_route(data.get("page_context")) - if ui_route: - lines.append(ui_route) + 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 963edb76..681ac63d 100644 --- a/tests/action/orchestrator/test_session_context.py +++ b/tests/action/orchestrator/test_session_context.py @@ -45,47 +45,45 @@ async def test_render_session_context_omits_channel_when_empty(): @pytest.mark.asyncio -async def test_render_session_context_includes_ui_route_from_page_context(): - from jvagent.action.orchestrator.session_context import format_ui_route +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) - page = { - "url": "/apps/n.WorkspaceApp.abc", - "route_path": "/apps/n.WorkspaceApp.abc", - "page_kind": "app_dashboards", - "breadcrumbs": [ - {"label": "Business Admin"}, - {"label": "Apps"}, - {"label": "Sales"}, - ], - "focused_app_id": "n.WorkspaceApp.abc", - "metadata": {"app_name": "Sales", "focused_dashboard_id": "n.Dashboard.1"}, - } - visitor = SimpleNamespace(channel="integral", data={"page_context": page}) + 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 "UI ROUTE (optional focus — not default answer scope):" in text - assert 'app="Sales"' in text - assert "app_id=n.WorkspaceApp.abc" in text - assert "kind=app_dashboards" in text - assert "crumbs=Business Admin › Apps › Sales" in text - assert "dashboard_id=n.Dashboard.1" in text - assert "merely because it is on screen" in text - # Compact block is also unit-testable alone. - block = format_ui_route(page) - assert block is not None - assert "Visible" not in block + 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_format_ui_route_messenger_fallback(): - from jvagent.action.orchestrator.session_context import format_ui_route - - block = format_ui_route({"title": "Pricing", "path": "/pricing"}) - assert block is not None - assert 'title="Pricing"' in block - assert "path=/pricing" in block - assert format_ui_route({}) is None - assert format_ui_route(None) is None +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