Skip to content

Dev - #412

Merged
jlunder00 merged 228 commits into
mainfrom
dev
Jul 3, 2026
Merged

Dev#412
jlunder00 merged 228 commits into
mainfrom
dev

Conversation

@jlunder00

Copy link
Copy Markdown
Owner

No description provided.

jlunder00 and others added 30 commits May 21, 2026 10:44
…line

Replace the 2.0 stub+1.0-fallback pattern with a real LayerClient pipeline
that starts a layer session, runs a single turn (haiku-4.5, max_turns=2,
permission_mode=auto, basic tether MCP tools only), forwards status and
agent_action events to the WS client via status_fn, and delivers the final
response on turn_complete.

tether-agent-2.5 remains stubbed — premium-pipeline-migrator owns that path.

Fallback behaviour (no user-visible stub):
- agent_layer.enabled=false in config → silently delegates to 1.0 pipeline
- httpx error (layer unavailable) → silently delegates to 1.0 pipeline
- asyncio.CancelledError → calls interrupt() then re-raises

Tests: 11 unit tests in tests/bot/test_agent_dispatch.py; 5 WS integration
tests in tests/api/test_agent_dispatch_ws.py updated to cover new 2.0
behaviour (layer happy path + silent fallback). 603 total passing.

Note: test_ws_stub_prepended_for_2x[tether-agent-2.0] is intentionally
replaced — 2.0 no longer sends a user-visible stub. 2.5 stub test preserved.
…gles

Part 1 — Live trial counter:
- agentPicker store: add trialMessagesRemaining (null until first WS event)
  and setTrialRemaining() action
- chat store: trial_usage_update WS event now calls setTrialRemaining instead
  of silently ignoring
- AgentPicker.vue: reads live count from store (removing trialMessagesLeft prop);
  shows "Upgrade to continue" overlay and locks 2.5 selection when count reaches 0

Part 2 — BYOK leakage gate UX:
- constants/agentProvider.ts: LEAKY_PROVIDERS = ['openrouter', 'openai'],
  DEFAULT_PROVIDER, isLeakyProvider() helper
- agentPicker store: currentProvider ref + isLeakyProvider computed; setAgent()
  silently blocks 2.5 selection when provider is leaky
- AgentPicker.vue: shows "Unavailable on current provider" explanation line and
  disables the 2.5 button when isLeakyProvider

Part 3 — Settings toggles:
- stores/agentSettings.ts: new store for auto_approve_user_actions and
  dev_mode_show_raw_tools; optimistic PUT with rollback via shared commitToggle()
- components/AgentBehaviorSection.vue: two toggle UI buttons wired to store actions
- SettingsView.vue: includes AgentBehaviorSection above LLM Configuration

Tests: 74 files, 761 tests all passing; zero type errors (npm run build clean).
- Add /chat router-link to main nav alongside Dashboard/Calendar/etc.
- New SideChatPanel.vue replaces BotChat in App.vue side drawer
  - Compact split layout: 160px ConversationList + flex-1 ConversationView
  - Emits close on button click or Escape
- App.vue: Ctrl+/ / Cmd+/ keyboard shortcut toggles panel
- PermissionModal mounted at App level (global, not inside side panel)
- Tests: SideChatPanel unit tests, AppNav link + keyboard shortcut tests
PermissionGate was structurally dormant: control_request SSE events
from the pool were never consumed, so all tool calls bypassed policy
enforcement.

Now run_turn inspects each event before passing to _translate_event.
control_request events are routed through PermissionGate.can_use_tool,
and the decision is forwarded to the pool via PoolClient.send_control_response.
Stale-request 404s (pool already timed out and denied) are swallowed so
slow-user scenarios complete cleanly. control_timeout events are skipped
silently. The dormant TODO(pool-permissions) comment is removed.

Adds 8 tests:
- background tool auto-allow
- user_action + auto_approve=True, allow without WS prompt
- user_action + permission_request WS event, user approves, allow
- user_action + user denies, deny
- control_timeout ignored, no send_control_response
- stale 404 on send_control_response swallowed, turn completes
- multiple control_requests in one turn
- integration: real PoolClient + FastAPI ControlBridge in-process
Extract the seven near-identical <router-link> blocks into a navLinks
array with an isActive() helper. Behavior preserved exactly:
- /plan, /dashboard, /calendar, /kanban, /chat use prefix matching
- /context, /anchors use exact matching (exact: true)
- Drops redundant active-class attributes that duplicated the :class
  active-state logic on /plan, /context, /anchors

All 736 frontend tests pass.
Wire agent_text_delta and future layer events (permission_request, etc.)
from the interactive-agent-layer SSE stream to the WS client via a new
async event_fn callback. Text deltas arrive as individual chunk frames,
giving browsers incremental rendering without waiting for turn_complete.

LayerClient.turn() previously buffered the entire SSE response before
yielding any events; it now drains complete SSE blocks as they arrive,
enabling true streaming delivery.

dispatch_message() and _dispatch_v2_0() gain an event_fn parameter.
bot_chat wires it to forward agent_text_delta as chunk frames and other
event types as-is. send_fn is skipped at turn_complete when deltas were
already streamed to prevent duplicate response content.

610 tests pass.
feat(frontend): trial counter, BYOK gate, agent settings toggles
feat(frontend): Chat nav link + Phase J side chat panel
WSPublisher uses in-process asyncio queues that don't cross the OS
process boundary between the layer service (:5003) and API service (:8000).
This meant permission_request events were silently dropped and PermissionGate
remained effectively dormant for user_action tools.

PermissionGate now enqueues permission_request events into an outbound
asyncio.Queue (injected by run_turn). The queue is drained concurrently
while the control_request handler awaits the user's decision, and each
event is yielded on the SSE stream so dispatch's event_fn can forward it
to the user's WebSocket across process boundaries.

Changes:
- PermissionGate: replace ws_publisher param with outbound_events queue;
  put() instead of ws.push() for permission_request
- session.run_turn: create gate_events queue, pass to gate; handle
  control_request via create_task + _drain_until_done so permission_request
  events flow on the SSE stream while awaiting the user decision
- Add _drain_until_done async generator: races queue.get() against task
  completion using asyncio.wait, exits promptly when task is done
- Update test_control_protocol: tests 2/3/4 now resolve futures inline
  from the yielded SSE stream instead of via ws.push mock
- Update test_permissions: replace blocking-ws pattern with queue-based
  helper; fixtures no longer take mock_ws

605/605 suite passing.
feat(bot): wire tether-agent-2.0 to interactive-agent-layer real pipeline
Wire A6 control protocol into interactive-agent-layer
… gate

Paid users are routed to the premium session handler (Beacon, memory, RAG).
Free users receive an upgrade notice and fall back to tether-agent-1.0.
ImportError and DB failures degrade gracefully to 1.0 fallback.

AgentSDKBackend not refactored: it is on the router fallback chain, not the
2.5 session path, and lacks user_id required by LayerClient.start_session().
…-2.5

Admin users have no subscription row but must reach the premium handler.
Add is_admin param to _dispatch_v25 and dispatch_message; when True, skip
the get_user_is_paid DB call entirely and go straight to the premium path.
Thread is_admin=websocket.state.is_admin from api/routes/bot.py through
dispatch_message into _dispatch_v25.

Two new tests: admin bypass reaches premium, is_admin flag forwarded.
- Remove test_v20_still_uses_stub: 2.0 now uses the real LayerClient pipeline
  (not a stub) — this test was written before PR #391 landed
- Replace test_ws_2_5_stub_prepended with test_ws_2_5_free_user_gets_upgrade_notice:
  2.5 now sends a Pro-plan upgrade notice (not a "coming soon" generic stub) for
  free users in the _dispatch_v25 path wired by M4
feat(dispatch): M4 — wire tether-agent-2.5 to premium handler with is_paid gate
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
fix: wire real PoolClient in layer __main__
set_user_setting(conn, key, value) uses RLS for user scoping — passing
request.state.user_id as a 4th positional argument caused TypeError 500.
fix(settings): remove extra user_id arg from set_user_setting call
…ew mount

Bug 1 — Can't scroll to 2.5:
AgentPicker dropdown used top-full mt-1 (opens downward) but the picker
lives in the composer bar at the bottom of ConversationView, which is inside
overflow-hidden ancestors in ChatPageView. The absolute-positioned dropdown
was clipped before all three options could be seen. Changed to bottom-full
mb-1 so the list opens upward where there is room.

Bug 2 — Selection resets to 2.0:
ConversationView was not calling fetchPreference() on mount, so the stored
agent preference was never loaded. The picker always started at the 2.0
default. Added onMounted(() => agentPickerStore.fetchPreference()) to match
BotChat's existing behaviour. The rollback to the previous value on a 500
response is correct optimistic-update behaviour and will resolve once the
backend set_user_setting() bug is fixed.
When pool_client.acquire() or any other run_turn() call raises, the
layer's event_generator() previously propagated the exception uncaught.
FastAPI's StreamingResponse had already committed the 200 status, so
Uvicorn closed the TCP connection without a terminal chunk. The httpx
client saw RemoteProtocolError (a subclass of httpx.HTTPError), which
dispatch caught as "layer unavailable" — a misleading message when the
layer itself was healthy but the pool was exhausted.

Fix 1 (interactive_agent_layer/server.py): wrap event_generator() in
try/except. On any exception, log it and emit {"type": "turn_error",
"message": str(exc)} as the final SSE event. The stream closes cleanly;
the client receives a parseable error signal instead of an abrupt close.

Fix 2 (bot/agent_dispatch.py): handle turn_error events explicitly —
log the real error message (e.g. "pool_exhausted — retry after 5s") at
WARNING and fall back to 1.0. Rename the httpx.HTTPError catch log to
"layer turn transport error" to distinguish raw network failures from
the in-band turn_error path.

628 tests pass (3 new: turn_error emission, turn_error fallback,
transport error log message).
fix(picker): open dropdown upward + load preference on mount
fix(bot): emit turn_error SSE event on layer run_turn failure
jlunder00 and others added 29 commits June 4, 2026 12:36
db: merge alembic migration heads (stream A + stream B)
… context

onMounted/onUnmounted are no-ops outside a Vue component context, so the
5 tests in usePoolWarm.test.ts all failed (0 API calls observed). Fix:

- Add withSetup() helper that mounts the composable inside a defineComponent
  so lifecycle hooks fire correctly; call app.unmount() for cleanup.
- Switch vi.useFakeTimers() to { toFake: ['setTimeout', 'clearTimeout'] }
  to leave Vitest's own scheduling intact (matches the pattern used in
  useDragEdgeScroll.test.ts).
- Add vi.runOnlyPendingTimers() before vi.useRealTimers() in afterEach.

5 previously failing tests now pass; full suite (947 tests) stays green.
write_node_memory:
- conversation_id now required; returns {error: conversation_id_required} if absent
- read-before-write check upgraded from advisory WARN to hard block for all
  write modes (additive, edit, delete); returns {error: read_before_write_required}
  if no prior node_read_log entry exists for the conversation
- removed v1 warning fields from success responses

read_context:
- conversation_id now required; returns {error: conversation_id_required} if absent
- scope envelope enforcement (out_of_scope structured errors) confirmed and tested

Tests:
- tests/mcp/test_write_node_memory_enforcement.py (12 tests, mock-based)
- tests/mcp/test_read_context_enforcement.py (10 tests, mock-based)
- Updated tests/tether_mcp/test_server.py to assert new error response shape
Replace stub implementations (always returning empty) with real
Postgres-backed search:

- search_context: tsvector FTS via plainto_tsquery over node_sections,
  ranked by ts_rank, supports path-restricted subtree search
- search_memory: ILIKE search over user_memory/user_durable_memory by
  key and value, relevance-ranked (exact > prefix > value match)

New pg_queries:
- db/pg_queries/sections.py: search_sections_fts() — FTS with score,
  node_name join, optional node_ids filter
- db/pg_queries/memory.py: search_user_memory() — ILIKE search with
  relevance scoring

Update server.py:
- Add paths param to search_context tool
- Fix stale 'v1 stub' docstrings on both tools

Tests: 23 new tests in tests/mcp/test_search_tools.py, all passing
fix(frontend): usePoolWarm composable tests — lifecycle hook context
feat(frontend): Stream D event renderer — action pills, status indicator
feat(mcp): harden write_node_memory read-before-write + read_context conversation_id enforcement
feat(mcp): implement search_context and search_memory tools
…it__

Add check_grant_fn and insert_grant_fn optional kwargs to Layer.__init__.
Both are threaded to PermissionGate in run_turn so premium callers can
inject DB-backed grant functions at startup without touching the public code.

PermissionGate already accepts and uses these fns (landed in dev via
feature/events-renderer). This PR closes the wiring gap: Layer now stores
and forwards them.

Matched premium PR: tether-premium feature/prompt-assembler (Stream C).
feat(layer): wire check_grant_fn / insert_grant_fn through Layer.__init__
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…uffer auto-evict

- read_context cascade now tracks _cascade_depth from the source node and
  stops descending when _cascade_depth >= N (scope envelope), preventing
  unbounded recursion when depth=-1 and N is set. N=0 preserves existing
  unlimited behavior.
- CoalescingBuffer.record() now calls self.evict_expired() on entry, keeping
  the cache bounded to the last window without requiring an external caller.
- Tests: 5 new cascade-gating unit tests (mocked DB), 1 new coalescing test.
…end dismiss

A — PermissionGate raises PermissionTimeoutError after 900s (configurable via
    agent_layer.permission_timeout_seconds; default bumped from 60→900).
    PermissionTimeoutError carries request_id for downstream event attribution.

B — _handle_control_request catches PermissionTimeoutError, sends deny to pool,
    re-raises. run_turn catches it, emits {type: session_timeout, session_id,
    reason: permission_timeout, request_id} on WebSocket, then ends cleanly.

C — Frontend: session_timeout event added to WsIncomingEvent type. Chat store
    handles it by clearing pendingPermissionRequest/permissionQueue, setting
    sessionTimedOut flag, and returning without sending permission_response.
    BotChat.vue shows "Session timed out — send a message to resume" on timeout.
    PermissionModal.vue 60s timer now dismisses to a compact waiting indicator
    (Option C) instead of auto-denying — backend is sole timeout authority.

Part D (read_out_of_scope mid-execution permission emission) deferred — requires
cross-process permission protocol design (MCP server and PermissionGate are in
separate processes).
feat(mcp): M-level descent gating in read_context cascade + CoalescingBuffer fix
feat: permission_request timeout + session_timeout event + frontend dismiss
Implements M-hop scope enforcement for read_context tool calls:

- PermissionGate gains optional scope params (source_node_id, scope_radius,
  scope_mode, hop_distance_fn, resolve_node_path_fn); None = no enforcement,
  so all existing callers are unaffected.
- When scope is active: read_context targets (node_ids + resolved paths) are
  checked against the session's source node via undirected tree distance.
  Targets beyond M hops (or unresolvable) trigger a permission_request with
  kind="read_out_of_scope" and await user approval before proceeding.
- add get_node_hop_distance(conn, from_id, to_id) to db/pg_queries/nodes.py:
  LCA-based recursive CTE over context_nodes; returns int hops or None for
  unrelated trees. Performance note: add ltree/closure table for large trees.
- Session options keys: scope_source_node_id, scope_radius. Bot dispatcher
  and frontend session-start will populate these in a follow-on PR.
- scope_mode defaults to "tree_distance" (undirected); "descendant_only"
  flag is reserved for future use.
- 24 new tests (16 PermissionGate, 8 hop distance); all 165 IAL tests pass.
feat(layer): read_out_of_scope scope gating for read_context via PermissionGate
Splits Dependabot #458 (dompurify + esbuild + vite 6->8 group bump)
to take only the dompurify security fix. dompurify is the production
XSS sanitizer used in MessageBubble.vue, so this closes real advisories
without pulling in the vite 8 major migration.
Prod managed Postgres never suspended compute because create_pool() held
min_size=2 connections open 24/7 across all four consumers (api, bot, mcp,
agent_pool_manager) — managed PG only suspends compute at zero connections,
so the monthly compute-time quota drained continuously and started 500ing
every DB call.

Change create_pool() to min_size=0 and add max_inactive_connection_lifetime=60
so pools release connections during idle windows and let compute suspend,
even while the fly machine itself stays up 24/7 for bot long-polling.

Tradeoff: first DB hit after an idle window pays managed-PG cold-resume
latency; covered by the existing 30s acquire/command timeouts.

Follow-up (not fixed here, pre-existing): bot/message_handler.py's
_notify_api() posts to /api/notify with no auth headers, which requires
admin auth — it silently 401s today. Unrelated to this fix; needs its own
ticket if live WS refresh-on-bot-activity is wanted.
fix(deps): bump dompurify to 3.4.11 for XSS advisory patches
fix(db): pool min_size=0 + idle lifetime so managed PG can scale to zero
@jlunder00
jlunder00 merged commit ad8e22f into main Jul 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant