Skip to content

Commit bb15f38

Browse files
Altruistusclaude
andcommitted
CM-72541: Re-report the conversation model when the IDE switches models
The model reaches the platform only through the session-start hook, and switching models does not start a new conversation, so every event after a switch was attributed to the model the conversation opened with. Each scan hook now compares the model in its payload against the model this device last reported for the conversation and re-reports the conversation when it changed. The last reported model is cached in ~/.cycode, so an unchanged model costs no extra request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 055cf3a commit bb15f38

8 files changed

Lines changed: 476 additions & 6 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Keep the reported conversation model in sync with the model the IDE is actually using.
2+
3+
The conversation record - and with it the model the platform attributes every event in that
4+
conversation to - is created by the session-start hook. Switching models does not start a new
5+
conversation (Cursor keeps the same ``conversation_id`` when the composer's model changes, and
6+
no ``sessionStart`` fires), so the model captured at session start goes stale the moment the
7+
user picks a different one.
8+
9+
Every hook payload carries the model that is configured *now*, so each hook compares it against
10+
the model this device last reported for the conversation and re-reports the conversation when it
11+
changed. The last reported model is cached on disk, which keeps the common case - the model did
12+
not change - free of any extra request.
13+
"""
14+
15+
import json
16+
import os
17+
import time
18+
from dataclasses import replace
19+
from pathlib import Path
20+
from typing import TYPE_CHECKING
21+
22+
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
23+
from cycode.logger import get_logger
24+
25+
if TYPE_CHECKING:
26+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
27+
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
28+
29+
logger = get_logger('AI Guardrails')
30+
31+
_CACHE_FILE_NAME = '.conversation-model-cache'
32+
# A conversation untouched for a week falls out of the cache; resuming it re-reports the model once.
33+
_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
34+
_MAX_CACHED_CONVERSATIONS = 200
35+
36+
37+
def _conversation_model_cache_path() -> Path:
38+
return Path.home() / '.cycode' / _CACHE_FILE_NAME
39+
40+
41+
def _seen_at(entry: dict) -> float:
42+
try:
43+
return float(entry.get('seen_at', 0))
44+
except (TypeError, ValueError):
45+
return 0.0
46+
47+
48+
def _load_cache() -> dict[str, dict]:
49+
"""Read the cache. A missing or corrupt file reads as empty - over-reporting is harmless."""
50+
try:
51+
cache = json.loads(_conversation_model_cache_path().read_text(encoding='utf-8'))
52+
except Exception:
53+
return {}
54+
55+
if not isinstance(cache, dict):
56+
return {}
57+
58+
return {conversation_id: entry for conversation_id, entry in cache.items() if isinstance(entry, dict)}
59+
60+
61+
def _prune(cache: dict[str, dict]) -> dict[str, dict]:
62+
"""Drop expired entries and bound the file by the most recently seen conversations."""
63+
now = time.time()
64+
fresh = {
65+
conversation_id: entry for conversation_id, entry in cache.items() if now - _seen_at(entry) < _CACHE_TTL_SECONDS
66+
}
67+
if len(fresh) <= _MAX_CACHED_CONVERSATIONS:
68+
return fresh
69+
70+
newest = sorted(fresh.items(), key=lambda item: _seen_at(item[1]), reverse=True)
71+
return dict(newest[:_MAX_CACHED_CONVERSATIONS])
72+
73+
74+
def _write_cache(cache: dict[str, dict]) -> None:
75+
path = _conversation_model_cache_path()
76+
path.parent.mkdir(parents=True, exist_ok=True)
77+
78+
# Hooks of the same generation run concurrently (a prompt and the files it makes the agent read),
79+
# and a half-written file reads back as a miss for every conversation on the device - swap it in
80+
# atomically instead.
81+
tmp_path = path.parent / f'{path.name}.{os.getpid()}.tmp'
82+
tmp_path.write_text(json.dumps(cache), encoding='utf-8')
83+
os.replace(tmp_path, path)
84+
85+
86+
def remember_reported_model(payload: 'AIHookPayload') -> None:
87+
"""Record the model the backend was last told about for this conversation. Never raises."""
88+
if not payload.conversation_id or not payload.model:
89+
return
90+
91+
try:
92+
cache = _load_cache()
93+
cache[payload.conversation_id] = {
94+
'model': payload.model,
95+
'source': payload.source,
96+
'seen_at': time.time(),
97+
}
98+
_write_cache(_prune(cache))
99+
except Exception as e:
100+
logger.debug('Failed to update the conversation model cache', exc_info=e)
101+
102+
103+
def sync_conversation_model(ai_client: 'AISecurityManagerClient', payload: 'AIHookPayload') -> bool:
104+
"""Re-report the conversation when the IDE switched models since the last reported hook.
105+
106+
Returns whether the conversation was re-reported. Only ``HttpUnauthorizedError`` propagates
107+
(the scan command turns it into an auth message); model tracking never fails a hook otherwise.
108+
"""
109+
if not payload.conversation_id or not payload.model:
110+
return False
111+
112+
try:
113+
cached = _load_cache().get(payload.conversation_id, {})
114+
if cached.get('model') == payload.model:
115+
return False
116+
117+
logger.debug(
118+
'Conversation model changed, re-reporting the conversation',
119+
extra={
120+
'conversation_id': payload.conversation_id,
121+
'reported_model': cached.get('model'),
122+
'current_model': payload.model,
123+
},
124+
)
125+
126+
# Hook payloads carry no session `source`; replay the one session start reported so the
127+
# update does not blank it.
128+
updated = replace(payload, source=payload.source or cached.get('source'))
129+
if not ai_client.create_conversation(updated):
130+
# The report failed: leave the cache alone so the next hook tries again.
131+
return False
132+
133+
remember_reported_model(updated)
134+
return True
135+
except HttpUnauthorizedError:
136+
raise
137+
except Exception as e:
138+
logger.debug('Failed to sync the conversation model', exc_info=e)
139+
return False

‎cycode/cli/apps/ai_guardrails/scan/scan_command.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step.
88
"""
99

10-
from typing import Annotated, Optional, Union
10+
from typing import TYPE_CHECKING, Annotated, Optional, Union
1111
from uuid import uuid4
1212

1313
import click
1414
import typer
1515

1616
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
1717
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
18+
from cycode.cli.apps.ai_guardrails.scan.conversation_model import sync_conversation_model
1819
from cycode.cli.apps.ai_guardrails.scan.detach import is_detached_child, respawn_detached
1920
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event, should_detach_scan
2021
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
@@ -24,6 +25,9 @@
2425
from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client
2526
from cycode.logger import get_logger
2627

28+
if TYPE_CHECKING:
29+
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
30+
2731
logger = get_logger('AI Guardrails')
2832

2933

@@ -61,8 +65,8 @@ def _deny_for_event(
6165
return HookDecision.deny(target, user_message, agent_message)
6266

6367

64-
def _initialize_clients(ctx: typer.Context) -> None:
65-
"""Initialize API clients.
68+
def _initialize_clients(ctx: typer.Context) -> 'AISecurityManagerClient':
69+
"""Initialize API clients and return the AI security one.
6670
6771
May raise click.ClickException if credentials are missing,
6872
or HttpUnauthorizedError if credentials are invalid.
@@ -73,6 +77,8 @@ def _initialize_clients(ctx: typer.Context) -> None:
7377
ai_security_client = get_ai_security_manager_client(ctx)
7478
ctx.obj['ai_security_client'] = ai_security_client
7579

80+
return ai_security_client
81+
7682

7783
def scan_command(
7884
ctx: typer.Context,
@@ -147,7 +153,11 @@ def scan_command(
147153
return
148154

149155
try:
150-
_initialize_clients(ctx)
156+
ai_security_client = _initialize_clients(ctx)
157+
158+
# Before the event is reported: the model may have changed since session start, and the
159+
# event has to land on a conversation that names the model the user is actually running.
160+
sync_conversation_model(ai_security_client, unified_payload)
151161

152162
decision = handler(ctx, unified_payload, policy)
153163
logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value})

‎cycode/cli/apps/ai_guardrails/session_start_command.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
collect_all_skills,
1616
get_ide,
1717
)
18+
from cycode.cli.apps.ai_guardrails.scan.conversation_model import remember_reported_model
1819
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
1920
from cycode.cli.apps.auth.auth_common import get_authorization_info
2021
from cycode.cli.apps.auth.auth_manager import AuthManager
@@ -161,7 +162,10 @@ def session_start_command(
161162

162163
# Create conversation
163164
try:
164-
ai_client.create_conversation(session_payload)
165+
if ai_client.create_conversation(session_payload):
166+
# Seed the model cache so the first hook of the session doesn't re-report what was
167+
# just reported; from here on the scan hook owns keeping the model up to date.
168+
remember_reported_model(session_payload)
165169
except Exception as e:
166170
logger.debug('Failed to create conversation during session start', exc_info=e)
167171

‎cycode/cyclient/ai_security_manager_client.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ def _build_endpoint_path(self, path: str) -> str:
3131
return path
3232

3333
def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]:
34-
"""Creates an AI conversation from hook payload."""
34+
"""Create (or update) the AI conversation described by the hook payload.
35+
36+
Returns the conversation id once the backend has it, or None when there was nothing to
37+
report or the report failed - callers that cache what was reported must not cache a
38+
report that never landed.
39+
"""
3540
conversation_id = payload.conversation_id
3641
if not conversation_id:
3742
return None
@@ -53,6 +58,7 @@ def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]:
5358
except Exception as e:
5459
logger.debug('Failed to create conversation', exc_info=e)
5560
# Don't fail the hook if tracking fails (non-auth errors)
61+
return None
5662

5763
return conversation_id
5864

0 commit comments

Comments
 (0)