Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .planning/adr/0056-ui-route-session-context.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/ORCHESTRATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
38 changes: 36 additions & 2 deletions jvagent/action/orchestrator/session_context.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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 (
Expand All @@ -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"


Expand Down
47 changes: 45 additions & 2 deletions tests/action/orchestrator/test_session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading