feat(lens): flag traces with global System 1 signals (#45094)

* feat(lens): add global trace signals

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* feat(lens): show global trace signals in Settings and Traces

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(proxy): resolve lens signal router at call time

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* feat(lens): signal library, all signal pills and an always-on Signals column

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): tidy signal setup and stop trace ids crowding signal pills

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): address review on signal scans, claims and settings drafts

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): resume signal scan at a partially consumed page

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): keep custom signals separate from library signals

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): give toggled library signals their own row keys

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* test(lens): restore signal toggle-off checks

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(lens): map signal spans and remove recursive helpers

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-07 13:27:22 -07:00 • committed by GitHub
parent d474b433cf
commit 8caf271e6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3377 additions and 62 deletions

View file

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS "LiteLLM_LensSignalConfig" (
"id" TEXT NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "LiteLLM_LensSignalConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "LiteLLM_LensTraceSignal" (
"trace_id" TEXT NOT NULL,
"trace_ref" TEXT NOT NULL DEFAULT '',
"config_key" TEXT NOT NULL,
"span_count" INTEGER NOT NULL,
"claimed_until" TIMESTAMP(3),
"classified_at" TIMESTAMP(3),
"data" JSONB NOT NULL,
CONSTRAINT "LiteLLM_LensTraceSignal_pkey" PRIMARY KEY ("trace_id", "trace_ref")
);

View file

@ -1977,3 +1977,20 @@ model LiteLLM_LensDataset {
@@id([id, revision])
}
model LiteLLM_LensSignalConfig {
id String @id
data Json
}
model LiteLLM_LensTraceSignal {
trace_id String
trace_ref String @default("")
config_key String
span_count Int
claimed_until DateTime?
classified_at DateTime?
data Json
@@id([trace_id, trace_ref])
}

View file

@ -353,6 +353,8 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_LensRun",
"LiteLLM_LensReview",
"LiteLLM_LensWorker",
"LiteLLM_LensSignalConfig",
"LiteLLM_LensTraceSignal",
)
)
PRISMA_RELATIONS: Final[frozenset[str]] = _PRISMA_MODELS | _PRISMA_VIEWS

View file

@ -52,6 +52,8 @@ from litellm.proxy.lens.models import (
from litellm.proxy.lens.release import PROTOCOL_VERSION, release_tag, worker_image
from litellm.proxy.lens.repository import DueLens, LensRepository, WriterDatabase
from litellm.proxy.lens.reviews import criteria_key
from litellm.proxy.lens.signal_repository import SignalRepository
from litellm.proxy.lens.signals import SignalConfig, TraceSignals, trace_signals
from litellm.proxy.lens.sources import ActivityAvailability, SourceReader, Storage, parse_execution
from litellm.proxy.lens.state import (
can_access,
@ -69,6 +71,7 @@ from litellm.proxy.lens.state import (
summarized,
)
from litellm.proxy.tracing_runtime import provide_storage
from litellm.router import Router
from litellm.types.llms.base import LiteLLMBaseModel
router: Final = APIRouter(prefix="/lens", tags=["Lens"])
@ -101,6 +104,14 @@ def repository() -> LensRepository:
return LensRepository(WriterDatabase(writer_wrapper(prisma_client.db)))
def signals_repository() -> SignalRepository:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(503, "Lens needs a connected Postgres database")
return SignalRepository(WriterDatabase(writer_wrapper(prisma_client.db)))
def source_reader(storage: Storage | None) -> SourceReader:
if storage is None:
raise HTTPException(
@ -118,6 +129,20 @@ def user_scope(auth: UserAPIKeyAuth, write: bool = False) -> Scope:
raise HTTPException(403, "Lens requires proxy administrator access")
def validate_signal_model(config: SignalConfig, llm_router: Router | None) -> None:
if not config.model:
return
message: Final = "Choose a System 1 model (evaluation mode) configured on this proxy"
if llm_router is None:
raise HTTPException(400, message)
try:
model_group: Final = llm_router.get_model_group_info(model_group=config.model)
except Exception as error:
raise HTTPException(400, message) from error
if model_group is None or model_group.mode != "evaluation":
raise HTTPException(400, message)
async def get_lens(lens_id: str, scope: Scope) -> Lens:
lens: Final = await repository().get(lens_id)
if lens is None or not can_access(scope, lens.scope):
@ -261,6 +286,39 @@ async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]:
return await source_reader(storage).agents(scope) if storage is not None else ()
@router.get("/signals", response_model=SignalConfig)
async def get_signals(auth: Auth) -> SignalConfig:
user_scope(auth)
return await signals_repository().get_config()
@router.put("/signals", response_model=SignalConfig)
async def put_signals(body: SignalConfig, auth: Auth) -> SignalConfig:
user_scope(auth, write=True)
from litellm.proxy.proxy_server import llm_router
validate_signal_model(body, llm_router)
await signals_repository().save_config(body)
return body
@router.post("/traces/signals", response_model=tuple[TraceSignals, ...])
async def trace_signal_statuses(body: TraceFindingsRequest, auth: Auth) -> tuple[TraceSignals, ...]:
user_scope(auth)
repo: Final = signals_repository()
config: Final = await repo.get_config()
existing: Final = await repo.traces(body.traces)
rows: Final = MappingProxyType({(row.trace_id, row.trace_ref): row for row in existing})
return tuple(
trace_signals(
trace,
rows.get((trace.trace_id, trace.trace_ref)),
config,
)
for trace in body.traces
)
@router.post("/traces/findings", response_model=tuple[TraceFindingCount, ...])
async def trace_findings(body: TraceFindingsRequest, auth: Auth) -> tuple[TraceFindingCount, ...]:
user_scope(auth)

View file

@ -0,0 +1,142 @@
import json
from datetime import datetime
from typing import Final
from pydantic import TypeAdapter
from litellm.proxy.lens.models import Execution, TraceIdentity
from litellm.proxy.lens.repository import Database, Row
from litellm.proxy.lens.signals import (
SIGNAL_RECLASSIFY_AFTER,
SIGNAL_RETRY_FAILED_AFTER,
SignalAttempt,
SignalConfig,
StoredTraceSignal,
)
_ROWS: Final[TypeAdapter[tuple[Row, ...]]] = TypeAdapter(tuple[Row, ...])
class SignalRepository:
def __init__(self, db: Database) -> None:
self.db: Final = db
async def get_config(self) -> SignalConfig:
rows: Final = _ROWS.validate_python(
await self.db.query_raw('SELECT data FROM "LiteLLM_LensSignalConfig" WHERE id=$1', "global")
)
return SignalConfig() if not rows else SignalConfig.model_validate(rows[0].data)
async def save_config(self, config: SignalConfig) -> None:
await self.db.execute_raw(
"""INSERT INTO "LiteLLM_LensSignalConfig" (id, data)
VALUES ($1, $2::jsonb)
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data""",
"global",
json.dumps(config.model_dump(mode="json")),
)
async def traces(self, identities: tuple[TraceIdentity, ...]) -> tuple[StoredTraceSignal, ...]:
if not identities:
return ()
payload: Final = json.dumps(
tuple({"trace_id": trace.trace_id, "trace_ref": trace.trace_ref} for trace in identities)
)
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
"""SELECT jsonb_build_object(
'trace_id', trace_id,
'trace_ref', trace_ref,
'config_key', config_key,
'span_count', span_count,
'claimed_until', claimed_until,
'classified_at', classified_at,
'data', data
) AS data
FROM "LiteLLM_LensTraceSignal"
WHERE (trace_id, trace_ref) IN (
SELECT trace_id, trace_ref FROM jsonb_to_recordset($1::jsonb) AS requested(
trace_id text, trace_ref text
)
)""",
payload,
)
)
return tuple(StoredTraceSignal.model_validate(row.data) for row in rows)
async def claim(
self,
execution: Execution,
config: SignalConfig,
claimed_until: datetime,
now: datetime,
) -> bool:
data: Final = json.dumps({"status": "pending", "scores": {}, "model": config.model, "error": ""})
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
"""INSERT INTO "LiteLLM_LensTraceSignal" AS stored
(trace_id, trace_ref, config_key, span_count, claimed_until, classified_at, data)
VALUES ($1, $2, $3, $4, $5::timestamp, NULL, $6::jsonb)
ON CONFLICT (trace_id, trace_ref) DO UPDATE SET
config_key=EXCLUDED.config_key,
span_count=EXCLUDED.span_count,
claimed_until=EXCLUDED.claimed_until,
classified_at=NULL,
data=EXCLUDED.data
WHERE (stored.claimed_until IS NULL OR stored.claimed_until < $7::timestamp)
AND (
stored.config_key IS DISTINCT FROM EXCLUDED.config_key
OR (
stored.data->>'status'='pending'
AND stored.claimed_until < $7::timestamp
)
OR (
EXCLUDED.span_count > stored.span_count
AND stored.classified_at < $8::timestamp
)
OR (
stored.data->>'status'='failed'
AND stored.classified_at < $9::timestamp
)
)
RETURNING jsonb_build_object('trace_id', trace_id) AS data""",
execution.trace_id,
execution.trace_ref,
config.key(),
execution.span_count,
claimed_until,
data,
now,
now - SIGNAL_RECLASSIFY_AFTER,
now - SIGNAL_RETRY_FAILED_AFTER,
)
)
return bool(rows)
async def store(
self,
execution: Execution,
config: SignalConfig,
claimed_until: datetime,
classified_at: datetime,
attempt: SignalAttempt,
) -> None:
payload: Final = json.dumps(
{
"status": attempt.status,
"scores": dict(attempt.scores),
"model": attempt.model,
"error": attempt.error,
}
)
await self.db.execute_raw(
"""UPDATE "LiteLLM_LensTraceSignal"
SET classified_at=$1::timestamp, claimed_until=NULL, data=$2::jsonb
WHERE trace_id=$3 AND trace_ref=$4 AND config_key=$5 AND claimed_until=$6::timestamp""",
classified_at,
payload,
execution.trace_id,
execution.trace_ref,
config.key(),
claimed_until,
)

View file

@ -0,0 +1,586 @@
import asyncio
import hashlib
import json
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone
from itertools import accumulate
from types import MappingProxyType
from typing import Annotated, Final, Literal, Protocol, TypeAlias
from pydantic import ConfigDict, Field, JsonValue, ValidationError, field_validator, model_validator
from litellm.integrations.clickhouse.context import lens_analysis
from litellm.litellm_core_utils.initialize_dynamic_callback_params import inherit_message_logging_privacy
from litellm.litellm_core_utils.secret_redaction import redact_internal_details
from litellm.proxy.lens.models import ActivitySelection, Execution, Record, Scope, TraceIdentity
from litellm.proxy.lens.sources import SourceReader, Storage
SIGNAL_INTERVAL_SECONDS: Final = 60
SIGNAL_PAGE_SIZE: Final = 100
SIGNAL_MAX_PER_TICK: Final = 50
SIGNAL_CONCURRENCY: Final = 8
SIGNAL_CLAIM_LEASE: Final = timedelta(minutes=5)
SIGNAL_RECLASSIFY_AFTER: Final = timedelta(minutes=5)
SIGNAL_RETRY_FAILED_AFTER: Final = timedelta(minutes=30)
SIGNAL_MAX_CONTENT_PAGES: Final = 3
SIGNAL_PART_MAX_CHARS: Final = 2000
SIGNAL_PART_HEAD_CHARS: Final = 800
SIGNAL_PART_TAIL_CHARS: Final = 1200
SIGNAL_TRANSCRIPT_MAX_CHARS: Final = 40000
SIGNAL_TRANSCRIPT_HEAD_CHARS: Final = 15000
SIGNAL_TRANSCRIPT_TAIL_CHARS: Final = 25000
SIGNAL_MAX_SCAN_PAGES: Final = 10
SIGNAL_TASK: Final = (
"An AI agent run recorded as a trace. Judge only what the user and the agent said and did in these steps."
)
class Signal(Record):
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
name: str = Field(min_length=1, max_length=60)
question: str = Field(min_length=3, max_length=500)
DEFAULT_SIGNALS: Final[tuple[Signal, ...]] = (
Signal(
id="user_frustration",
name="User frustration",
question=(
"Does the user show frustration, annoyance or dissatisfaction with the agent in this run, for example "
"complaints, irritated corrections, all caps, profanity, or giving up on the task?"
),
),
Signal(
id="missing_capability",
name="Missing capability",
question=(
"Does the user ask for something the agent cannot do in this run, so that the agent refuses, says it "
"lacks a tool, permission, integration or data source, or fails because the capability does not exist?"
),
),
Signal(
id="repeated_request",
name="Repeated request",
question=(
"Does the user ask for the same thing more than once in this run, usually because the agent did not "
"deliver it the first time?"
),
),
)
class SignalConfig(Record):
model: str = ""
threshold: float = Field(default=0.5, ge=0.05, le=0.95, allow_inf_nan=False)
signals: tuple[Signal, ...] = DEFAULT_SIGNALS
@model_validator(mode="after")
def validate_signals(self) -> "SignalConfig":
if len(self.signals) > 20:
raise ValueError("A maximum of 20 signals is allowed")
if len(frozenset(signal.id for signal in self.signals)) != len(self.signals):
raise ValueError("Signal IDs must be unique")
return self
@property
def enabled(self) -> bool:
return bool(self.model) and bool(self.signals)
def key(self) -> str:
payload: Final = json.dumps(
{
"model": self.model,
"signals": tuple({"id": signal.id, "question": signal.question} for signal in self.signals),
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode()).hexdigest()
Score: TypeAlias = Annotated[float, Field(ge=0, le=1, allow_inf_nan=False)]
class SignalFlag(Record):
signal_id: str
name: str
score: Score
class TraceSignals(TraceIdentity):
status: Literal["unclassified", "pending", "classified", "failed"]
flags: tuple[SignalFlag, ...] = ()
model: str = ""
classified_at: datetime | None = None
class SignalStep(Record):
kind: str
name: str
content: str
class SignalData(Record):
model_config = ConfigDict(extra="ignore")
status: Literal["pending", "classified", "failed"] = "pending"
scores: Mapping[str, Score] = Field(default_factory=lambda: MappingProxyType({}))
model: str = ""
error: str = ""
class SignalAttempt(Record):
status: Literal["classified", "failed"]
scores: Mapping[str, Score] = Field(default_factory=lambda: MappingProxyType({}))
model: str
error: str = ""
class StoredTraceSignal(Record):
trace_id: str
trace_ref: str = ""
config_key: str
span_count: int
claimed_until: datetime | None = None
classified_at: datetime | None = None
data: JsonValue
@field_validator("claimed_until", "classified_at")
@classmethod
def normalize_database_timestamp(cls, value: datetime | None) -> datetime | None:
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
class NoulAnswer(Record):
model_config = ConfigDict(extra="ignore", allow_inf_nan=False, from_attributes=True)
type: Literal["noul"]
noul: float = Field(ge=0, le=1, allow_inf_nan=False)
class DecisionsOutput(Record):
model_config = ConfigDict(extra="ignore", from_attributes=True)
answers: Mapping[str, object]
DecisionState: TypeAlias = Mapping[str, object]
DecisionQuestions: TypeAlias = Mapping[str, Mapping[str, str]]
Clock: TypeAlias = Callable[[], datetime]
RouterReady: TypeAlias = Callable[[], bool]
class DecisionsCall(Protocol):
async def __call__(
self,
*,
model: str,
state: DecisionState,
questions: DecisionQuestions,
timeout: float,
metadata: Mapping[str, object],
) -> object: ...
class SignalRepositoryProtocol(Protocol):
async def get_config(self) -> SignalConfig: ...
async def traces(self, identities: tuple[TraceIdentity, ...]) -> tuple[StoredTraceSignal, ...]: ...
async def claim(
self,
execution: Execution,
config: SignalConfig,
claimed_until: datetime,
now: datetime,
) -> bool: ...
async def store(
self,
execution: Execution,
config: SignalConfig,
claimed_until: datetime,
classified_at: datetime,
attempt: SignalAttempt,
) -> None: ...
def signal_identity(trace: TraceIdentity | StoredTraceSignal | Execution) -> tuple[str, str]:
return trace.trace_id, trace.trace_ref
def candidate(
trace: Execution,
existing: StoredTraceSignal | None,
config_key: str,
now: datetime,
) -> bool:
if existing is None:
return True
if existing.claimed_until is not None and existing.claimed_until > now:
return False
if existing.config_key != config_key:
return True
status: Final = existing.data.get("status") if isinstance(existing.data, dict) else ""
if status == "pending":
return existing.claimed_until is not None and existing.claimed_until <= now
if existing.span_count > trace.span_count:
return False
if existing.span_count < trace.span_count:
return existing.classified_at is not None and existing.classified_at < now - SIGNAL_RECLASSIFY_AFTER
return (
status == "failed"
and existing.classified_at is not None
and existing.classified_at < now - SIGNAL_RETRY_FAILED_AFTER
)
def _take_head(steps: tuple[SignalStep, ...], remaining: int) -> tuple[SignalStep, ...]:
if remaining <= 0:
return ()
cumulative_lengths: Final = tuple(accumulate(len(step.content) for step in steps))
boundary: Final = next((index for index, total in enumerate(cumulative_lengths) if total >= remaining), None)
if boundary is None:
return steps
preceding: Final = steps[:boundary]
last: Final = steps[boundary]
used: Final = cumulative_lengths[boundary - 1] if boundary > 0 else 0
last_length: Final = remaining - used
return (
*preceding,
last
if last_length == len(last.content)
else last.model_copy(update=MappingProxyType({"content": last.content[:last_length]})),
)
def _take_tail(steps: tuple[SignalStep, ...], remaining: int) -> tuple[SignalStep, ...]:
if remaining <= 0:
return ()
reversed_steps: Final = tuple(reversed(steps))
cumulative_lengths: Final = tuple(accumulate(len(step.content) for step in reversed_steps))
boundary: Final = next((index for index, total in enumerate(cumulative_lengths) if total >= remaining), None)
if boundary is None:
return steps
preceding: Final = reversed_steps[:boundary]
last: Final = reversed_steps[boundary]
used: Final = cumulative_lengths[boundary - 1] if boundary > 0 else 0
last_length: Final = remaining - used
selected: Final = (
*preceding,
last
if last_length == len(last.content)
else last.model_copy(update=MappingProxyType({"content": last.content[-last_length:]})),
)
return tuple(reversed(selected))
def _bounded_steps(steps: tuple[SignalStep, ...]) -> tuple[SignalStep, ...]:
if sum(len(step.content) for step in steps) <= SIGNAL_TRANSCRIPT_MAX_CHARS:
return steps
head: Final = _take_head(steps, SIGNAL_TRANSCRIPT_HEAD_CHARS)
tail: Final = _take_tail(steps, SIGNAL_TRANSCRIPT_TAIL_CHARS)
omitted_count: Final = len(steps) - len(head) - len(tail)
marker: Final = SignalStep(kind="omitted", name="", content=f"{omitted_count} steps omitted")
return (*head, marker, *tail)
def _part_excerpt(content: str) -> str:
if len(content) <= SIGNAL_PART_MAX_CHARS:
return content
omitted: Final = len(content) - SIGNAL_PART_MAX_CHARS
marker: Final = f"\n[... {omitted} characters omitted ...]\n"
return f"{content[:SIGNAL_PART_HEAD_CHARS]}{marker}{content[-SIGNAL_PART_TAIL_CHARS:]}"
async def _content_pages(
reader: SourceReader,
scope: Scope,
execution: Execution,
cursor: str,
pages_left: int,
) -> tuple[SignalStep, ...]:
if pages_left == 0:
return ()
content: Final = await reader.content(scope, execution, cursor)
current: Final = tuple(
SignalStep(kind=part.kind, name=part.name, content=_part_excerpt(part.content)) for part in content.parts
)
rest: Final = (
await _content_pages(reader, scope, execution, content.next_cursor, pages_left - 1)
if content.next_cursor is not None
else ()
)
return (*current, *rest)
async def signal_state(reader: SourceReader, scope: Scope, execution: Execution) -> DecisionState:
steps: Final = _bounded_steps(await _content_pages(reader, scope, execution, "", SIGNAL_MAX_CONTENT_PAGES))
return {
"task": SIGNAL_TASK,
"steps": tuple(step.model_dump(mode="json") for step in steps),
}
def _noul_score(value: object) -> float | None:
try:
return NoulAnswer.model_validate(value).noul
except ValidationError:
return None
class SignalClassifier:
def __init__(self, reader: SourceReader, completion: DecisionsCall, clock: Clock) -> None:
self.reader: Final = reader
self.completion: Final = completion
self.clock: Final = clock
async def classify(self, scope: Scope, execution: Execution, config: SignalConfig) -> SignalAttempt:
try:
state: Final = await signal_state(self.reader, scope, execution)
questions: Final = {
signal.id: {"type": "noul", "instructions": signal.question} for signal in config.signals
}
with lens_analysis(), inherit_message_logging_privacy(True):
response: Final = await self.completion(
model=config.model,
state=state,
questions=questions,
timeout=60,
metadata={"tags": ["litellm-lens-signals"]},
)
output: Final = DecisionsOutput.model_validate(response)
scores: Final = MappingProxyType(
{
signal.id: score
for signal in config.signals
if (score := _noul_score(output.answers.get(signal.id))) is not None
}
)
if len(scores) != len(config.signals):
return SignalAttempt(
status="failed",
scores=scores,
model=config.model,
error="Decisions response omitted a configured noul answer",
)
return SignalAttempt(status="classified", scores=scores, model=config.model)
except Exception as error:
detail: Final = redact_internal_details(str(error))[:300]
return SignalAttempt(status="failed", model=config.model, error=detail)
def trace_signals(
trace: TraceIdentity,
existing: StoredTraceSignal | None,
config: SignalConfig,
) -> TraceSignals:
if existing is None or existing.config_key != config.key():
return TraceSignals(trace_id=trace.trace_id, trace_ref=trace.trace_ref, status="unclassified")
data: Final = SignalData.model_validate(existing.data)
if data.status == "pending":
return TraceSignals(
trace_id=trace.trace_id,
trace_ref=trace.trace_ref,
status="pending",
model=data.model,
)
if data.status == "failed" or data.error:
return TraceSignals(
trace_id=trace.trace_id,
trace_ref=trace.trace_ref,
status="failed",
model=data.model,
classified_at=existing.classified_at,
)
flags: Final = tuple(
sorted(
(
SignalFlag(signal_id=signal.id, name=signal.name, score=data.scores[signal.id])
for signal in config.signals
if signal.id in data.scores and data.scores[signal.id] >= config.threshold
),
key=lambda flag: flag.score,
reverse=True,
)
)
return TraceSignals(
trace_id=trace.trace_id,
trace_ref=trace.trace_ref,
status="classified",
flags=flags,
model=data.model,
classified_at=existing.classified_at,
)
async def _process_claimed(
classifier: SignalClassifier,
repository: SignalRepositoryProtocol,
scope: Scope,
execution: Execution,
config: SignalConfig,
claimed_until: datetime,
) -> None:
from litellm._logging import verbose_proxy_logger
attempt: Final = await classifier.classify(scope, execution, config)
try:
await repository.store(execution, config, claimed_until, classifier.clock(), attempt)
except Exception as error:
verbose_proxy_logger.error("Lens signal result could not be stored: %s", redact_internal_details(str(error)))
class _SignalScan:
def __init__(
self,
reader: SourceReader,
repository: SignalRepositoryProtocol,
scope: Scope,
config: SignalConfig,
now: datetime,
cursor: str,
limit: int,
) -> None:
self.reader: Final = reader
self.repository: Final = repository
self.scope: Final = scope
self.config: Final = config
self.now: Final = now
self.cursor: str = cursor
self.limit: Final = limit
self.executions: tuple[Execution, ...] = ()
self.finished: bool = False
async def _read_page(self, start: int, end: int) -> tuple[tuple[Execution, ...], str | None]:
page_cursor: Final = self.cursor
sample: Final = await self.reader.sample(
self.scope,
ActivitySelection(source="traces"),
start,
end,
page_size=SIGNAL_PAGE_SIZE,
cursor=page_cursor,
)
identities: Final = tuple(
TraceIdentity(trace_id=trace.trace_id, trace_ref=trace.trace_ref) for trace in sample.executions
)
existing_rows: Final = await self.repository.traces(identities)
existing: Final = MappingProxyType({signal_identity(row): row for row in existing_rows})
remaining: Final = self.limit - len(self.executions)
all_eligible: Final = tuple(
execution
for execution in sample.executions
if candidate(execution, existing.get(signal_identity(execution)), self.config.key(), self.now)
)
eligible: Final = all_eligible[:remaining]
next_cursor: Final = page_cursor if len(all_eligible) > remaining else sample.next_cursor
return eligible, next_cursor
async def run(self) -> tuple[tuple[Execution, ...], str]:
start: Final = int((self.now - timedelta(hours=24)).timestamp() * 1000)
end: Final = int((self.now - timedelta(minutes=2)).timestamp() * 1000)
for _ in range(SIGNAL_MAX_SCAN_PAGES):
if self.finished or len(self.executions) >= self.limit:
break
eligible, next_cursor = await self._read_page(start, end)
self.executions = (*self.executions, *eligible)
if next_cursor is None:
self.cursor = ""
self.finished = True
else:
self.cursor = next_cursor
return self.executions, self.cursor
async def _scan_pages(
reader: SourceReader,
repository: SignalRepositoryProtocol,
scope: Scope,
config: SignalConfig,
now: datetime,
cursor: str,
remaining: int,
) -> tuple[tuple[Execution, ...], str]:
if remaining <= 0:
return (), cursor
scan: Final = _SignalScan(reader, repository, scope, config, now, cursor, remaining)
return await scan.run()
async def run_signal_tick(
storage: Storage,
repository: SignalRepositoryProtocol | None,
completion: DecisionsCall | None,
clock: Clock,
router_ready: RouterReady = lambda: True,
cursor: str = "",
) -> str:
if repository is None or completion is None or not router_ready():
return cursor
now: Final = clock()
config: Final = await repository.get_config()
if not config.enabled:
return cursor
reader: Final = SourceReader(storage)
scope: Final = Scope(all_teams=True)
candidates: Final = await _scan_pages(
reader,
repository,
scope,
config,
now,
cursor,
SIGNAL_MAX_PER_TICK,
)
executions, next_cursor = candidates
classifier: Final = SignalClassifier(reader, completion, clock)
semaphore: Final = asyncio.Semaphore(SIGNAL_CONCURRENCY)
async def process(execution: Execution) -> None:
from litellm._logging import verbose_proxy_logger
async with semaphore:
claimed_at: Final = classifier.clock()
claimed_until: Final = claimed_at + SIGNAL_CLAIM_LEASE
try:
claimed: Final = await repository.claim(execution, config, claimed_until, claimed_at)
except Exception as error:
verbose_proxy_logger.error("Lens signal claim failed: %s", redact_internal_details(str(error)))
return
if not claimed:
return
await _process_claimed(classifier, repository, scope, execution, config, claimed_until)
await asyncio.gather(*(process(execution) for execution in executions))
return next_cursor
class _SignalLoopState:
def __init__(self) -> None:
self.cursor: str = ""
async def run_signal_loop(
storage: Storage,
repository: SignalRepositoryProtocol | None,
completion: DecisionsCall | None,
clock: Clock = lambda: datetime.now(timezone.utc),
router_ready: RouterReady = lambda: True,
) -> None:
from litellm._logging import verbose_proxy_logger
state: Final = _SignalLoopState()
while True:
try:
state.cursor = await run_signal_tick(
storage,
repository,
completion,
clock,
router_ready,
cursor=state.cursor,
)
except Exception as error:
verbose_proxy_logger.error("Lens signal tick failed: %s", redact_internal_details(str(error)))
await asyncio.sleep(SIGNAL_INTERVAL_SECONDS)

View file

@ -581,6 +581,14 @@ from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger, run_sp
from litellm.proxy.image_endpoints.endpoints import router as image_router
from litellm.proxy.lens.dataset_endpoints import router as lens_dataset_router
from litellm.proxy.lens.endpoints import router as lens_router
from litellm.proxy.lens.repository import WriterDatabase
from litellm.proxy.lens.signal_repository import SignalRepository
from litellm.proxy.lens.signals import (
DecisionQuestions,
DecisionsCall,
DecisionState,
run_signal_loop,
)
from litellm.proxy.list_api.common import (
ManagementProblem,
problem_response,
@ -1275,6 +1283,27 @@ async def _connect_to_count_stored_values() -> SupportsRawQueries:
return client.writer_db
async def _call_current_lens_signal_router(
*,
model: str,
state: DecisionState,
questions: DecisionQuestions,
timeout: float,
metadata: Mapping[str, object],
) -> object:
current_router: Final = llm_router
if current_router is None:
raise RuntimeError("The proxy router is not initialized")
decisions: Final[DecisionsCall] = cast(DecisionsCall, current_router.adecisions)
return await decisions(
model=model,
state=state,
questions=questions,
timeout=timeout,
metadata=metadata,
)
@asynccontextmanager
async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState, None]:
global \
@ -1645,12 +1674,30 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState
state: Final[ProxyLifespanState] = {"tracing_receiver": receiver}
from litellm.proxy.admin_mcp import admin_mcp_lifespan
signal_completion: Final[DecisionsCall] = _call_current_lens_signal_router
signal_task: Final = (
asyncio.create_task(
run_signal_loop(
receiver.storage,
SignalRepository(WriterDatabase(writer_wrapper(prisma_client.db))),
signal_completion,
router_ready=lambda: llm_router is not None,
)
)
if receiver is not None and prisma_client is not None
else None
)
try:
async with AsyncExitStack() as admin_mcp_stack:
try:
await admin_mcp_stack.enter_async_context(admin_mcp_lifespan(app))
yield state
finally:
if signal_task is not None:
signal_task.cancel()
await asyncio.gather(signal_task, return_exceptions=True)
if model_info_scheduler is not None and model_info_scheduler.running:
model_info_scheduler.remove_job("refresh_model_info")
if model_info_scheduler is not scheduler:
@ -13951,7 +13998,7 @@ async def run_thread(
# )
# async def get_available_routes(user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth)):
from litellm.llms.base_llm.base_utils import BaseTokenCounter
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient, writer_wrapper
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.model_repository import ModelRepository
from litellm.repositories.table_repositories import (

View file

@ -1977,3 +1977,20 @@ model LiteLLM_LensDataset {
@@id([id, revision])
}
model LiteLLM_LensSignalConfig {
id String @id
data Json
}
model LiteLLM_LensTraceSignal {
trace_id String
trace_ref String @default("")
config_key String
span_count Int
claimed_until DateTime?
classified_at DateTime?
data Json
@@id([trace_id, trace_ref])
}

View file

@ -1977,3 +1977,20 @@ model LiteLLM_LensDataset {
@@id([id, revision])
}
model LiteLLM_LensSignalConfig {
id String @id
data Json
}
model LiteLLM_LensTraceSignal {
trace_id String
trace_ref String @default("")
config_key String
span_count Int
claimed_until DateTime?
classified_at DateTime?
data Json
@@id([trace_id, trace_ref])
}

View file

@ -1,25 +1,30 @@
from collections.abc import Callable
import asyncio
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm import Router
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.lens.endpoints import (
claim_due,
get_signals,
list_agents,
put_signals,
read_reviews,
result,
run_settings,
run_window,
trace_findings,
trace_signal_statuses,
user_scope,
validate_model,
validate_signal_model,
watchable,
watching,
worker_supports_model,
@ -43,6 +48,7 @@ from litellm.proxy.lens.models import (
Worker,
)
from litellm.proxy.lens.repository import DueLens, Row
from litellm.proxy.lens.signals import SignalConfig, StoredTraceSignal
from litellm.proxy.lens.state import claim_job, queue_job, replace_job
from litellm.rust_bridge.trace.generated.models import ExecutionRow, LensSampleParams
from tests.unit.proxy.lens.test_agent_workspace import execution
@ -71,6 +77,46 @@ class ResultDatabase:
return len(self.completed)
class SignalStatusDatabase:
def __init__(self, config: SignalConfig, rows: Mapping[str, StoredTraceSignal]) -> None:
self.config: Final = config
self.rows: Final = rows
self.saved: Final[asyncio.Queue[tuple[object, ...]]] = asyncio.Queue()
async def query_raw(self, query: str, *args: object) -> object:
if '"LiteLLM_LensSignalConfig"' in query:
return ({"data": self.config.model_dump(mode="json")},)
payload: Final = args[0]
assert isinstance(payload, str)
requested: Final = TypeAdapter(tuple[TraceIdentity, ...]).validate_json(payload)
return tuple(
{"data": row.model_dump(mode="json")}
for identity in requested
if (row := self.rows.get(identity.trace_id)) is not None
)
async def execute_raw(self, query: str, *args: object) -> int:
await self.saved.put(args)
return 1
def signal_router() -> Router:
return Router(
model_list=[
{
"model_name": "decision",
"litellm_params": {"model": "openai/test-decision", "api_key": "test-key"},
"model_info": {"mode": "evaluation"},
},
{
"model_name": "chat",
"litellm_params": {"model": "openai/test-chat", "api_key": "test-key"},
"model_info": {"mode": "chat"},
},
]
)
@pytest.mark.asyncio
async def test_worker_sample_retries_oversized_pages_and_keeps_all_executions(
monkeypatch: pytest.MonkeyPatch,
@ -469,6 +515,144 @@ async def test_trace_finding_counts_require_investigation_read_access() -> None:
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_signal_endpoints_return_statuses_in_request_order_for_admin_viewers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy import proxy_server
config: Final = SignalConfig(model="decision")
rows: Final = {
"pending": StoredTraceSignal(
trace_id="pending",
config_key=config.key(),
span_count=1,
claimed_until=NOW + timedelta(minutes=1),
data={"status": "pending", "scores": {}, "model": "decision", "error": ""},
),
"classified": StoredTraceSignal(
trace_id="classified",
config_key=config.key(),
span_count=1,
classified_at=NOW,
data={
"status": "classified",
"scores": {"user_frustration": 0.7, "missing_capability": 0.8},
"model": "decision",
"error": "",
},
),
"failed": StoredTraceSignal(
trace_id="failed",
config_key=config.key(),
span_count=1,
classified_at=NOW,
data={"status": "failed", "scores": {}, "model": "decision", "error": "classification failed"},
),
"stale": StoredTraceSignal(
trace_id="stale",
config_key="old-config",
span_count=1,
classified_at=NOW,
data={
"status": "classified",
"scores": {"user_frustration": 1.0},
"model": "old",
"error": "",
},
),
}
database: Final = SignalStatusDatabase(config, rows)
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=database))
viewer: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
request: Final = TraceFindingsRequest(
traces=tuple(
TraceIdentity(trace_id=trace_id) for trace_id in ("failed", "classified", "missing", "pending", "stale")
)
)
assert await get_signals(viewer) == config
results: Final = await trace_signal_statuses(request, viewer)
assert tuple((result.trace_id, result.status) for result in results) == (
("failed", "failed"),
("classified", "classified"),
("missing", "unclassified"),
("pending", "pending"),
("stale", "unclassified"),
)
assert tuple((flag.signal_id, flag.name, flag.score) for flag in results[1].flags) == (
("missing_capability", "Missing capability", 0.8),
("user_frustration", "User frustration", 0.7),
)
@pytest.mark.asyncio
async def test_signal_endpoints_require_connected_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "prisma_client", None)
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
with pytest.raises(HTTPException) as error:
await get_signals(auth)
assert error.value.status_code == 503
@pytest.mark.asyncio
async def test_put_signals_saves_config_for_admin(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
database: Final = SignalStatusDatabase(SignalConfig(), {})
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=database))
monkeypatch.setattr(proxy_server, "llm_router", signal_router())
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
body: Final = SignalConfig(model="decision", threshold=0.7)
assert await put_signals(body, auth) == body
saved: Final = await database.saved.get()
assert saved[0] == "global"
assert isinstance(saved[1], str)
assert SignalConfig.model_validate_json(saved[1]) == body
def test_signal_model_requires_a_ready_router() -> None:
with pytest.raises(HTTPException) as error:
validate_signal_model(SignalConfig(model="decision"), None)
assert error.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("role", (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
async def test_put_signals_rejects_non_admin_roles(role: LitellmUserRoles) -> None:
auth: Final = UserAPIKeyAuth(user_role=role)
with pytest.raises(HTTPException) as error:
await put_signals(SignalConfig(model="decision"), auth)
assert error.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ("chat", "unconfigured"))
async def test_put_signals_rejects_chat_and_unknown_model_groups(monkeypatch: pytest.MonkeyPatch, model: str) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", signal_router())
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as error:
await put_signals(SignalConfig(model=model), auth)
assert error.value.status_code == 400
assert error.value.detail == "Choose a System 1 model (evaluation mode) configured on this proxy"
def test_signal_model_accepts_only_evaluation_mode_groups() -> None:
assert validate_signal_model(SignalConfig(model="decision"), signal_router()) is None
@pytest.mark.parametrize(
"role",
(LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.TEAM),

File diff suppressed because it is too large Load diff

View file

@ -291,7 +291,7 @@ describe("Lens interactive demo", () => {
await expectUrl(onUrlUpdate, (url) => expect(url.get("tab")).toBe("settings"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const panel = within(screen.getByRole("region", { name: "Settings" }));
expect(panel.getByRole("status")).toHaveTextContent("Tracing enabled");
expect(panel.getByText("Tracing enabled", { exact: true })).toBeVisible();
expect(panel.getByRole("heading", { name: "Analysis worker" })).toBeVisible();
expect(panel.getByRole("heading", { name: worker.name })).toBeVisible();
expect(panel.getByText("Connected")).toBeVisible();

View file

@ -196,6 +196,7 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
readOnly={readOnly}
canMintTracingKey={isAdmin}
canViewFindings={canViewInvestigations}
onSetUpSignals={canConfigure ? showSettings : undefined}
/>
</TabsContent>
<TabsContent value="findings" className={PANEL}>

View file

@ -37,6 +37,8 @@ function demoLensApi(data: LensDemoData): LensApi {
saveLens: readOnly,
startRun: readOnly,
watchAll: async () => ({ watching: [], skipped: [] }),
signalConfig: async () => ({ model: "", threshold: 0.5, signals: [] }),
saveSignalConfig: readOnly,
cancelRun: readOnly,
reviewFinding: readOnly,
registerWorker: readOnly,
@ -90,6 +92,8 @@ function demoTracesApi(data: LensDemoData): TracesApi {
);
return { ...trace, finding_count: assessed.length ? findings.size : null };
}),
signals: async (traces) =>
traces.map((trace) => ({ ...trace, status: "unclassified" as const, flags: [], model: "", classified_at: null })),
anyRecorded: async () => data.runs.length > 0,
trace: (traceId) => found(run(traceId)?.trace),
span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)),

View file

@ -25,6 +25,7 @@ export const lensKeys = {
models: (scope: string) => [...lensKeys.all, "models", { scope }] as const,
modelDetails: (scope: string) => [...lensKeys.all, "model-details", { scope }] as const,
activity: (scope: string) => [...lensKeys.all, "activity-available", { scope }] as const,
signalConfig: (scope: string) => [...lensKeys.all, "signal-config", { scope }] as const,
discoveries: () => [...lensKeys.all, "discovery"] as const,
discovery: (scope: string, source: Settings["source"], hours: number | undefined) =>
[...lensKeys.discoveries(), { scope, source, hours }] as const,
@ -46,6 +47,13 @@ export const lensQueries = {
modelDetails(api: LensApi) {
return queryOptions({ queryKey: lensKeys.modelDetails(api.scope), queryFn: () => api.modelDetails() });
},
signalConfig(api: LensApi) {
return queryOptions({
queryKey: lensKeys.signalConfig(api.scope),
queryFn: () => api.signalConfig(),
staleTime: 5000,
});
},
activity(api: LensApi, loaded: boolean) {
const options = {
queryKey: lensKeys.activity(api.scope),

View file

@ -13,6 +13,7 @@ import type {
RunWindow,
Sample,
Settings,
SignalConfig,
WorkerCreated,
} from "../model/types";
@ -62,6 +63,8 @@ export interface LensApi {
saveLens(id: string | undefined, settings: Settings): Promise<Lens>;
startRun(lensId: string, request?: RunWindow): Promise<void>;
watchAll(): Promise<components["schemas"]["WatchAllResult"]>;
signalConfig(): Promise<SignalConfig>;
saveSignalConfig(config: SignalConfig): Promise<SignalConfig>;
cancelRun(lensId: string): Promise<void>;
reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise<void>;
registerWorker(analysisKeyId: string): Promise<WorkerCreated>;
@ -168,6 +171,8 @@ export function liveLensApi(client: LensClient, apiClient: ApiClient, accessToke
),
startRun: (lensId, request = {}) => sent(client.POST("/lens/{lens_id}/runs", { ...lens(lensId), body: request })),
watchAll: () => required(client.POST("/lens/watch-all", { headers })),
signalConfig: () => required(client.GET("/lens/signals", { headers })),
saveSignalConfig: (config) => required(client.PUT("/lens/signals", { headers, body: config })),
cancelRun: (lensId) => sent(client.POST("/lens/{lens_id}/cancel", lens(lensId))),
reviewFinding: (lensId, findingId, status, reason) =>
sent(

View file

@ -0,0 +1,74 @@
import type { AnalysisModelInfo, SignalConfig } from "./types";
export const SYSTEM_ONE_MODE = "evaluation";
export const signalsConfigured = (config: SignalConfig): boolean =>
Boolean(config.model) && (config.signals?.length ?? 0) > 0;
export const systemOneModels = (details: readonly AnalysisModelInfo[]): AnalysisModelInfo[] =>
details
.filter((info) => info.mode === SYSTEM_ONE_MODE)
.toSorted((a, b) => a.model_group.localeCompare(b.model_group));
export interface LibrarySignal {
readonly id: string;
readonly name: string;
readonly summary: string;
readonly question: string;
}
export const SIGNAL_LIBRARY: readonly LibrarySignal[] = [
{
id: "user_frustration",
name: "User frustration",
summary: "annoyed, complaining or giving up",
question:
"Does the user show frustration, annoyance or dissatisfaction with the agent in this run, for example complaints, irritated corrections, all caps, profanity, or giving up on the task?",
},
{
id: "missing_capability",
name: "Missing capability",
summary: "asked for something the agent can't do",
question:
"Does the user ask for something the agent cannot do in this run, so that the agent refuses, says it lacks a tool, permission, integration or data source, or fails because the capability does not exist?",
},
{
id: "repeated_request",
name: "Repeated request",
summary: "had to ask for the same thing again",
question:
"Does the user ask for the same thing more than once in this run, usually because the agent did not deliver it the first time?",
},
{
id: "asked_for_human",
name: "Asked for a human",
summary: "wants a person, not the agent",
question: "Does the user ask to talk to a human, a support person or a manager instead of the agent in this run?",
},
{
id: "tool_failure",
name: "Tool failure",
summary: "a tool or step failed and stayed broken",
question:
"Does a tool call or step fail in this run with an error, exception or timeout that the agent does not recover from?",
},
{
id: "refused_request",
name: "Refused request",
summary: "declined a reasonable ask",
question: "Does the agent refuse or decline a reasonable, allowed user request in this run?",
},
{
id: "made_up_answer",
name: "Made-up answer",
summary: "facts or links with no source",
question:
"Does the agent state facts, numbers, links or tool results in this run that are not supported by the conversation or by any tool output?",
},
{
id: "task_abandoned",
name: "Task abandoned",
summary: "run ended without what was asked",
question: "Does the run end without the user getting what they asked for?",
},
];

View file

@ -5,6 +5,8 @@ export type Lens = components["schemas"]["Lens"];
export type Settings = components["schemas"]["LensSettings"];
export type LensList = components["schemas"]["LensList"];
export type SignalConfig = components["schemas"]["SignalConfig"];
export type Signal = NonNullable<SignalConfig["signals"]>[number];
export type Finding = components["schemas"]["Finding"];

View file

@ -5,6 +5,7 @@ import { Activity, ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StatusDot } from "@/components/shared/StatusDot";
import { WorkerSettings } from "./worker/WorkerSettings";
import { SignalSettings } from "./signals/SignalSettings";
import { SettingsCard, SettingsSection } from "./SettingsSection";
import type { LensList } from "../model/types";
@ -53,6 +54,7 @@ export function LensSettings({
return (
<div aria-label="Settings" role="region" className="flex w-full flex-col divide-y divide-border">
<TracingSection enabled={list.tracing_enabled} onOpenTraces={onOpenTraces} />
<SignalSettings />
<SettingsSection
heading="Analysis worker"
description="Runs investigations on your server and bills model usage to an analysis key."

View file

@ -0,0 +1,120 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithLens } from "@/../tests/lens-test-utils";
import { testQueryClient } from "@/../tests/test-utils";
import type { SignalConfig } from "../../model/types";
import { SignalForm } from "./SignalSettings";
const saved: SignalConfig = {
model: "jev",
threshold: 0.5,
signals: [{ id: "user_frustration", name: "User frustration", question: "Is the user frustrated?" }],
};
const updated: SignalConfig = {
...saved,
model: "new-jev",
threshold: 0.8,
};
const network = vi.fn<typeof fetch>();
describe("signal settings", () => {
beforeEach(() => {
testQueryClient.clear();
network.mockReset();
network.mockImplementation(async (input) => {
const path = new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname;
return Response.json(path === "/model_group/info" ? { data: [] } : {});
});
vi.stubGlobal("fetch", network);
});
it("updates a clean draft when saved settings change", async () => {
const { rerender } = renderWithLens(<SignalForm saved={saved} />);
const model = await screen.findByRole("combobox", { name: "System 1 model" });
expect(model).toHaveValue("jev");
rerender(<SignalForm saved={updated} />);
expect(model).toHaveValue("new-jev");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps dirty edits until the user loads the latest settings", async () => {
const user = userEvent.setup();
const { rerender } = renderWithLens(<SignalForm saved={saved} />);
const threshold = await screen.findByRole("spinbutton", { name: "Flag at score" });
fireEvent.change(threshold, { target: { value: "70" } });
rerender(<SignalForm saved={updated} />);
expect(threshold).toHaveValue(70);
expect(screen.getByRole("alert")).toHaveTextContent("Signals were changed elsewhere");
await user.click(screen.getByRole("button", { name: "Load latest" }));
expect(threshold).toHaveValue(80);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps a custom Tool failure signal separate while toggling the library signal", async () => {
const user = userEvent.setup();
const customQuestion = "Does this custom failure condition apply?";
const customSignal = { id: "tool_failure", name: "Tool failure", question: customQuestion };
const customConfig: SignalConfig = {
...saved,
signals: [customSignal],
};
renderWithLens(<SignalForm saved={customConfig} />);
const question = await screen.findByRole("textbox", { name: "Question for Tool failure" });
const model = await screen.findByRole("combobox", { name: "System 1 model" });
const threshold = screen.getByRole("spinbutton", { name: "Flag at score" });
expect(question).toHaveValue(customQuestion);
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "false");
expect(model).toHaveValue("jev");
expect(threshold).toHaveValue(50);
await user.click(screen.getByRole("button", { name: /^Tool failure/ }));
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "true");
expect(question).toHaveValue(customQuestion);
const editedQuestion = "Does this edited custom failure condition apply?";
fireEvent.change(question, { target: { value: editedQuestion } });
expect(question).toHaveValue(editedQuestion);
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "Remove Tool failure" }));
expect(screen.queryByRole("textbox", { name: "Question for Tool failure" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: /^Tool failure/ }));
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByText("Pick at least one signal to flag traces")).toBeInTheDocument();
expect(model).toHaveValue("jev");
expect(threshold).toHaveValue(50);
});
it("removes only the library signal when toggling it off beside a custom signal", async () => {
const user = userEvent.setup();
const customQuestion = "Does this custom failure condition apply?";
const customConfig: SignalConfig = {
...saved,
signals: [{ id: "tool_failure", name: "Tool failure", question: customQuestion }],
};
renderWithLens(<SignalForm saved={customConfig} />);
const question = await screen.findByRole("textbox", { name: "Question for Tool failure" });
const tile = screen.getByRole("button", { name: /^Tool failure/ });
await user.click(tile);
expect(tile).toHaveAttribute("aria-pressed", "true");
await user.click(tile);
expect(screen.getByRole("button", { name: /^Tool failure/ })).toHaveAttribute("aria-pressed", "false");
expect(question).toHaveValue(customQuestion);
});
});

View file

@ -0,0 +1,296 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flag, Trash2 } from "lucide-react";
import Link from "next/link";
import { useId, useState } from "react";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { StatusDot } from "@/components/shared/StatusDot";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { uiHref } from "@/utils/uiHref";
import { useLensApi } from "../../data/LensServices";
import { lensKeys, lensQueries } from "../../data/queries";
import { SIGNAL_LIBRARY, signalsConfigured, systemOneModels } from "../../model/signals";
import { WatchPicker } from "../../setup/WatchPicker";
import type { SignalConfig } from "../../model/types";
import { SettingsCard, SettingsSection } from "../SettingsSection";
import {
MAX_SIGNALS,
configFrom,
draftFrom,
draftProblems,
newRow,
type SignalDraft,
type SignalRow,
} from "./signalDraft";
const LIBRARY_QUESTIONS: ReadonlySet<string> = new Set(SIGNAL_LIBRARY.map((signal) => signal.question));
export function SignalSettings() {
return (
<SettingsSection
heading="Signals"
description="Set once for every trace. A System 1 model checks each run and Traces flags matches in red."
>
<SignalConfigLoader />
</SettingsSection>
);
}
function SignalConfigLoader() {
const api = useLensApi();
const config = useQuery(lensQueries.signalConfig(api));
if (config.error)
return (
<p role="alert" className="text-sm text-destructive">
Could not load signals: {config.error.message}
</p>
);
if (!config.data) return <Skeleton aria-label="Loading signals" className="h-40 w-full" />;
return <SignalForm saved={config.data} />;
}
const FieldError = ({ children }: { children?: string }) =>
children ? <p className="mt-1 text-xs text-destructive">{children}</p> : null;
function SetupCallout({ hasModels }: { hasModels: boolean }) {
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<Flag aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="space-y-1">
<p className="font-medium">Choose a System 1 model to start flagging traces</p>
{hasModels ? (
<p className="text-xs text-muted-foreground">Pick one of the evaluation models on this proxy below.</p>
) : (
<p className="text-xs text-muted-foreground">
This proxy has no System 1 models yet. Add one with mode evaluation, for example typesafe/jev-latest, on{" "}
<Link
href={uiHref("models-and-endpoints")}
className="font-medium text-foreground underline underline-offset-2"
>
Models + Endpoints
</Link>
.
</p>
)}
</div>
</div>
);
}
function SignalFields({
row,
problems,
onChange,
onRemove,
}: {
row: SignalRow;
problems?: { name?: string; question?: string };
onChange: (row: SignalRow) => void;
onRemove: () => void;
}) {
const label = row.name.trim() || "new signal";
return (
<li className="grid gap-2 rounded-md border border-border p-3 sm:grid-cols-[200px_minmax(0,1fr)_auto]">
<div>
<Input
aria-label="Signal name"
placeholder="User frustration"
value={row.name}
aria-invalid={Boolean(problems?.name)}
onChange={(event) => onChange({ ...row, name: event.target.value })}
/>
<FieldError>{problems?.name}</FieldError>
</div>
<div>
<Textarea
aria-label={`Question for ${label}`}
placeholder="Does the user show frustration with the agent in this run?"
rows={2}
value={row.question}
aria-invalid={Boolean(problems?.question)}
onChange={(event) => onChange({ ...row, question: event.target.value })}
/>
<FieldError>{problems?.question}</FieldError>
</div>
<Button variant="ghost" size="icon-sm" aria-label={`Remove ${label}`} onClick={onRemove}>
<Trash2 aria-hidden="true" />
</Button>
</li>
);
}
export function SignalForm({ saved }: { saved: SignalConfig }) {
const api = useLensApi();
const queryClient = useQueryClient();
const modelId = useId();
const thresholdId = useId();
const [draft, setDraft] = useState<SignalDraft>(() => draftFrom(saved));
const [draftBase, setDraftBase] = useState<SignalConfig>(() => saved);
const [savedElsewhere, setSavedElsewhere] = useState(false);
const savedKey = JSON.stringify(saved);
const draftBaseKey = JSON.stringify(draftBase);
if (savedKey !== draftBaseKey) {
const draftIsDirty = JSON.stringify(configFrom(draft)) !== JSON.stringify(configFrom(draftFrom(draftBase)));
setDraftBase(saved);
if (draftIsDirty) {
setSavedElsewhere(true);
} else {
setDraft(draftFrom(saved));
setSavedElsewhere(false);
}
}
const details = useQuery(lensQueries.modelDetails(api));
const models = systemOneModels(details.data?.data ?? []);
const options = models.map((info) => ({
value: info.model_group,
label: info.model_group,
sublabel: info.providers.join(", "),
}));
const problems = draftProblems(draft);
const next = configFrom(draft);
const dirty = JSON.stringify(next) !== JSON.stringify(configFrom(draftFrom(draftBase)));
const save = useMutation({
mutationFn: (config: SignalConfig) => api.saveSignalConfig(config),
onSuccess: (config) => {
queryClient.setQueryData(lensKeys.signalConfig(api.scope), config);
setDraft(draftFrom(config));
setDraftBase(config);
setSavedElsewhere(false);
void queryClient.invalidateQueries({ queryKey: ["traceSignals"] });
},
});
const setRows = (rows: readonly SignalRow[]) => setDraft((current) => ({ ...current, rows }));
const addRow = () => setRows([...draft.rows, newRow(crypto.randomUUID())]);
const active = signalsConfigured(saved);
const custom = draft.rows.filter((row) => !LIBRARY_QUESTIONS.has(row.question));
const picked = new Set(
SIGNAL_LIBRARY.filter((signal) => draft.rows.some((row) => row.question === signal.question)).map(
(signal) => signal.id,
),
);
const pick = (next: ReadonlySet<string>) =>
setRows([
...SIGNAL_LIBRARY.filter((signal) => next.has(signal.id)).map(
(signal) =>
draft.rows.find((row) => row.question === signal.question) ?? {
key: crypto.randomUUID(),
id: draft.rows.some((row) => row.id === signal.id) ? "" : signal.id,
name: signal.name,
question: signal.question,
},
),
...custom,
]);
const loadLatest = () => {
setDraft(draftFrom(saved));
setDraftBase(saved);
setSavedElsewhere(false);
};
return (
<>
<SettingsCard className="space-y-4">
<p role="status" className="inline-flex items-center gap-2 text-sm">
<StatusDot state={active ? "ok" : "off"} />
{active ? `Flagging traces with ${saved.model}` : "Signals are off"}
</p>
{!saved.model && !details.isPending && <SetupCallout hasModels={models.length > 0} />}
{savedElsewhere && (
<div role="alert" className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
<span>Signals were changed elsewhere</span>
<Button type="button" variant="outline" size="sm" onClick={loadLatest}>
Load latest
</Button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_160px]">
<div className="space-y-1.5">
<label htmlFor={modelId} className="text-sm font-medium">
System 1 model
</label>
<SearchSelect
inputId={modelId}
aria-label="System 1 model"
options={options}
value={draft.model}
onValueChange={(value) => setDraft((current) => ({ ...current, model: value ?? "" }))}
placeholder={details.isPending ? "Loading models…" : "Choose a System 1 model"}
disabled={details.isPending}
emptyText="No System 1 models on this proxy"
allowClear
/>
<p className="text-xs text-muted-foreground">
Decisions API models onboarded with mode evaluation, such as TypeSafe JEV
</p>
</div>
<div className="space-y-1.5">
<label htmlFor={thresholdId} className="text-sm font-medium">
Flag at score
</label>
<div className="flex items-center gap-1.5">
<Input
id={thresholdId}
type="number"
inputMode="numeric"
min={5}
max={95}
step={5}
value={Number.isNaN(draft.thresholdPercent) ? "" : draft.thresholdPercent}
aria-invalid={Boolean(problems.threshold)}
onChange={(event) =>
setDraft((current) => ({ ...current, thresholdPercent: event.target.valueAsNumber }))
}
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<FieldError>{problems.threshold}</FieldError>
</div>
</div>
<div className="space-y-4 border-t border-border pt-4">
<WatchPicker
label="Flag runs where"
options={SIGNAL_LIBRARY}
selected={picked}
onChange={pick}
onAddCustom={addRow}
addDisabled={draft.rows.length >= MAX_SIGNALS}
/>
{custom.length > 0 && (
<ul aria-label="Custom signals" className="space-y-2">
{custom.map((row) => (
<SignalFields
key={row.key}
row={row}
problems={problems.rows.get(row.key)}
onChange={(changed) => setRows(draft.rows.map((other) => (other.key === row.key ? changed : other)))}
onRemove={() => setRows(draft.rows.filter((other) => other.key !== row.key))}
/>
))}
</ul>
)}
{draft.rows.length === 0 && (
<p className="text-xs text-muted-foreground">Pick at least one signal to flag traces</p>
)}
<FieldError>{problems.signals}</FieldError>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
{save.error && (
<p role="alert" className="text-sm text-destructive">
Could not save signals: {save.error.message}
</p>
)}
{save.isSuccess && !dirty && <p className="text-xs text-muted-foreground">Saved</p>}
<Button disabled={!dirty || problems.any || save.isPending} onClick={() => save.mutate(next)}>
{save.isPending ? "Saving…" : "Save signals"}
</Button>
</div>
</SettingsCard>
</>
);
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { SIGNAL_LIBRARY } from "../../model/signals";
import { configFrom, draftFrom, draftProblems, newRow, signalIds } from "./signalDraft";
const saved = {
model: "jev",
threshold: 0.5,
signals: [{ id: "user_frustration", name: "User frustration", question: "Is the user frustrated?" }],
};
describe("signal drafts", () => {
it("round-trips a saved config unchanged", () => {
expect(configFrom(draftFrom(saved))).toEqual(saved);
});
it("keeps saved IDs on rename and derives unique IDs for new signals", () => {
const rows = [
{ ...draftFrom(saved).rows[0], name: "Annoyed user" },
{ ...newRow("a"), name: "Repeat request!" },
{ ...newRow("b"), name: "Repeat request?" },
{ ...newRow("c"), name: "2nd try" },
];
expect(signalIds(rows)).toEqual(["user_frustration", "repeat_request", "repeat_request_2", "signal_2nd_try"]);
});
it("does not assign a library ID to a new custom question", () => {
const rows = [{ ...newRow("custom"), name: "Tool failure", question: "Does this custom signal apply?" }];
expect(signalIds(rows)[0]).not.toBe("tool_failure");
});
it("keeps custom and library signals distinct when their names match", () => {
const toolFailure = SIGNAL_LIBRARY.find((signal) => signal.id === "tool_failure");
if (!toolFailure) throw new Error("Tool failure is missing from the signal library");
const customQuestion = "Does this custom signal apply?";
const draft = {
model: "jev",
thresholdPercent: 50,
rows: [
{ ...newRow("library"), name: toolFailure.name, question: toolFailure.question },
{ ...newRow("custom"), id: toolFailure.id, name: toolFailure.name, question: customQuestion },
],
};
expect(configFrom(draft).signals).toEqual([
{ id: "tool_failure_2", name: "Tool failure", question: toolFailure.question },
{ id: "tool_failure", name: "Tool failure", question: customQuestion },
]);
});
it("reports blank, duplicate and out of range fields", () => {
const draft = {
model: "",
thresholdPercent: 99,
rows: [
{ ...newRow("a"), name: "Loop", question: "Does the agent loop?" },
{ ...newRow("b"), name: " loop ", question: "?" },
newRow("c"),
],
};
const problems = draftProblems(draft);
expect(problems.any).toBe(true);
expect(problems.threshold).toBe("Use a whole number from 5 to 95");
expect(problems.rows.get("a")).toBeUndefined();
expect(problems.rows.get("b")).toEqual({
name: "Another signal has this name",
question: "Ask a yes or no question about the run",
});
expect(problems.rows.get("c")?.name).toBe("Name the signal");
});
});

View file

@ -0,0 +1,116 @@
import type { SignalConfig } from "../../model/types";
import { SIGNAL_LIBRARY } from "../../model/signals";
export const MAX_SIGNALS = 20;
const MAX_NAME = 60;
const MAX_QUESTION = 500;
const MAX_ID = 64;
export interface SignalRow {
readonly key: string;
readonly id: string;
readonly name: string;
readonly question: string;
}
export interface SignalDraft {
readonly model: string;
readonly thresholdPercent: number;
readonly rows: readonly SignalRow[];
}
export interface RowProblems {
readonly name?: string;
readonly question?: string;
}
export interface DraftProblems {
readonly threshold?: string;
readonly signals?: string;
readonly rows: ReadonlyMap<string, RowProblems>;
readonly any: boolean;
}
export const draftFrom = (config: SignalConfig): SignalDraft => ({
model: config.model ?? "",
thresholdPercent: Math.round((config.threshold ?? 0.5) * 100),
rows: (config.signals ?? []).map((signal) => ({
key: signal.id,
id: signal.id,
name: signal.name,
question: signal.question,
})),
});
export const newRow = (key: string): SignalRow => ({ key, id: "", name: "", question: "" });
const slug = (name: string): string => {
const words = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const lettered = /^[a-z]/.test(words) ? words : `signal_${words}`.replace(/_+$/, "");
return lettered.slice(0, MAX_ID);
};
const uniqueId = (base: string, taken: ReadonlySet<string>, attempt = 1): string => {
const suffix = attempt === 1 ? "" : `_${attempt}`;
const id = `${base.slice(0, MAX_ID - suffix.length)}${suffix}`;
return taken.has(id) ? uniqueId(base, taken, attempt + 1) : id;
};
export const signalIds = (rows: readonly SignalRow[]): string[] => {
const savedIds: ReadonlySet<string> = new Set(rows.flatMap((row) => (row.id ? [row.id] : [])));
return rows.reduce<string[]>((ids, row) => {
if (row.id) return [...ids, row.id];
const librarySignal = SIGNAL_LIBRARY.find((signal) => signal.question === row.question);
const libraryIds = new Set(
SIGNAL_LIBRARY.filter((signal) => signal.id !== librarySignal?.id).map((signal) => signal.id),
);
const taken = new Set([...savedIds, ...ids, ...libraryIds]);
return [...ids, uniqueId(librarySignal?.id ?? slug(row.name), taken)];
}, []);
};
const rowProblems = (row: SignalRow, duplicateName: boolean): RowProblems => {
const name = row.name.trim();
const question = row.question.trim();
const nameProblem = [
!name ? "Name the signal" : undefined,
name.length > MAX_NAME ? `Keep the name under ${MAX_NAME} characters` : undefined,
duplicateName ? "Another signal has this name" : undefined,
].find((problem) => problem !== undefined);
const questionProblem = [
question.length < 3 ? "Ask a yes or no question about the run" : undefined,
question.length > MAX_QUESTION ? `Keep the question under ${MAX_QUESTION} characters` : undefined,
].find((problem) => problem !== undefined);
return { name: nameProblem, question: questionProblem };
};
export function draftProblems(draft: SignalDraft): DraftProblems {
const names = draft.rows.map((row) => row.name.trim().toLowerCase());
const rows = new Map(
draft.rows
.map((row, index): [string, RowProblems] => [
row.key,
rowProblems(row, names[index] !== "" && names.indexOf(names[index]) !== index),
])
.filter(([, problems]) => problems.name || problems.question),
);
const { thresholdPercent } = draft;
const threshold =
Number.isInteger(thresholdPercent) && thresholdPercent >= 5 && thresholdPercent <= 95
? undefined
: "Use a whole number from 5 to 95";
const signals = draft.rows.length > MAX_SIGNALS ? `Use at most ${MAX_SIGNALS} signals` : undefined;
return { threshold, signals, rows, any: Boolean(threshold || signals || rows.size) };
}
export function configFrom(draft: SignalDraft): SignalConfig {
const ids = signalIds(draft.rows);
return {
model: draft.model,
threshold: draft.thresholdPercent / 100,
signals: draft.rows.map((row, index) => ({ id: ids[index], name: row.name.trim(), question: row.question.trim() })),
};
}

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { watches } from "../model/watches";
import { watches as defaultWatches } from "../model/watches";
import { cn } from "@/lib/cva.config";
const dotColors = ["#8b5cf6", "#22b3e8", "#e3a32b", "#eb6b93", "#22b3e8", "#8b5cf6", "#e3a32b", "#eb6b93"];
@ -75,14 +75,26 @@ function DotFlow({ active }: { active: readonly string[] }) {
);
}
export interface PickerOption {
readonly id: string;
readonly name: string;
readonly summary: string;
}
export function WatchPicker({
selected,
onChange,
onAddCustom,
options: watches = defaultWatches,
label = "Watch for",
addDisabled = false,
}: {
selected: ReadonlySet<string>;
onChange: (next: ReadonlySet<string>) => void;
onAddCustom: () => void;
options?: readonly PickerOption[];
label?: string;
addDisabled?: boolean;
}) {
const [cursor, setCursor] = useState(0);
const items = useRef<(HTMLButtonElement | null)[]>([]);
@ -105,18 +117,20 @@ export function WatchPicker({
} else return;
event.preventDefault();
};
const activeColors = watches.flatMap((watch, index) => (selected.has(watch.id) ? [dotColors[index]] : []));
const activeColors = watches.flatMap((watch, index) =>
selected.has(watch.id) ? [dotColors[index % dotColors.length]] : [],
);
return (
<fieldset className="space-y-2.5">
<div className="flex items-end justify-between gap-3">
<legend className="text-sm font-medium">Watch for</legend>
<legend className="text-sm font-medium">{label}</legend>
<span className="text-xs tabular-nums text-muted-foreground">
{selected.size} of {watches.length} selected
{watches.filter((watch) => selected.has(watch.id)).length} of {watches.length} selected
</span>
</div>
<DotFlow active={activeColors} />
<div role="group" aria-label="Watch for" onKeyDown={onKey} className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<div role="group" aria-label={label} onKeyDown={onKey} className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
{watches.map((watch, index) => {
const on = selected.has(watch.id);
return (
@ -164,7 +178,8 @@ export function WatchPicker({
<button
type="button"
onClick={onAddCustom}
className="flex h-11 w-full items-center gap-2.5 rounded-xl bg-muted/60 px-3.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={addDisabled}
className="flex h-11 w-full items-center gap-2.5 rounded-xl bg-muted/60 px-3.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<span
aria-hidden="true"

View file

@ -19,6 +19,7 @@ import type {
TracePage,
TraceFindingCount,
TraceFindingsRequest,
TraceSignals,
} from "./types";
export interface TraceWindow {
@ -38,6 +39,7 @@ export interface TracesApi {
handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff;
list(window: TraceWindow): Promise<TracePage>;
findings(traces: TraceFindingsRequest["traces"]): Promise<TraceFindingCount[]>;
signals(traces: TraceFindingsRequest["traces"]): Promise<TraceSignals[]>;
anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;
@ -82,6 +84,11 @@ export function liveTracesApi(accessToken: string): TracesApi {
accessToken,
body: { traces } satisfies TraceFindingsRequest,
}),
signals: (traces) =>
apiClient.post<TraceSignals[]>("/lens/traces/signals", {
accessToken,
body: { traces } satisfies TraceFindingsRequest,
}),
anyRecorded: async () => {
const page = await apiClient.get<TracePage>("/v1/traces", {
accessToken,

View file

@ -16,7 +16,8 @@ import { traceRefOf, traceShareUrl } from "../../routing";
import { IdChip } from "../../ui/IdChip";
import { SpanIcon } from "../../ui/SpanIcon";
import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework";
import type { Trace } from "../../types";
import type { SignalFlag, Trace } from "../../types";
import { SignalPills } from "../../ui/SignalPills";
import { fmtMs, fmtTok, traceAgentNames, traceDisplayName } from "../../utils";
interface CopyButtonProps {
@ -90,6 +91,7 @@ interface RunHeaderProps {
live: boolean;
canLive: boolean;
onLiveChange: () => void;
signals?: readonly SignalFlag[];
}
/** Run identity, view switch and totals in two tight rows. */
@ -103,6 +105,7 @@ export function RunHeader({
live,
canLive,
onLiveChange,
signals = [],
}: RunHeaderProps) {
const { summary } = trace;
const failed = summary.status === "error";
@ -162,6 +165,7 @@ export function RunHeader({
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<StatusPill failed={failed} />
{signals.length > 0 && <SignalPills flags={signals} showScore className="flex-wrap" />}
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />

View file

@ -17,6 +17,7 @@ import type { Trace } from "../../types";
import { PagingBanner } from "./PagingBanner";
import { RunBody } from "./RunBody";
import { RunHeader } from "./RunHeader";
import { useTraceSignalFlags } from "../../list/useTraceSignals";
interface RunViewProps {
traceId: string;
@ -26,6 +27,7 @@ interface RunViewProps {
onBack: () => void;
/** Rendered inside the side drawer: the drawer owns closing and sizing. */
embedded?: boolean;
showSignals?: boolean;
}
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
@ -87,6 +89,7 @@ function LoadedRun({
accessToken,
onBack,
embedded = false,
showSignals = false,
switching,
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
@ -108,6 +111,7 @@ function LoadedRun({
retryDelay: traceReadRetryDelay,
};
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const signals = useTraceSignalFlags(accessToken, { trace_id: traceId, trace_ref: traceRef }, showSignals);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const failure = traceQuery.isFetchNextPageError ? classifyTraceReadFailure(traceQuery.error) : null;
const readManually = (read: () => Promise<unknown>) => {
@ -169,6 +173,7 @@ function LoadedRun({
live={live}
canLive={traces.live}
onLiveChange={toggleLive}
signals={signals}
/>
{traceQuery.isRefetchError && (
<div role="alert" className="flex items-center gap-3 border-b p-3 text-xs text-muted-foreground">

View file

@ -11,12 +11,14 @@ export default function AgentTracesPage({
readOnly = false,
canMintTracingKey = false,
canViewFindings = true,
onSetUpSignals,
}: {
accessToken: string;
isActive?: boolean;
readOnly?: boolean;
canMintTracingKey?: boolean;
canViewFindings?: boolean;
onSetUpSignals?: () => void;
}) {
const time = useRelativeRange(useTracesLive());
return (
@ -28,6 +30,7 @@ export default function AgentTracesPage({
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
canViewFindings={canViewFindings}
onSetUpSignals={onSetUpSignals}
timeControls={time}
/>
</div>

View file

@ -1,6 +1,7 @@
"use client";
import moment from "moment";
import { skipToken, useQuery } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import { traceAgentNames } from "../utils";
import { useMemo, useState } from "react";
@ -12,6 +13,10 @@ import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { useTraceFindings } from "./useTraceFindings";
import { useTraceSignals } from "./useTraceSignals";
import { useOptionalLensApi } from "../../data/LensServices";
import { lensKeys } from "../../data/queries";
import { signalsConfigured } from "../../model/signals";
import { type TraceRef, traceKey, traceRefOf, useOpenTraceRouting, useRunFilterRouting } from "../routing";
import type { TraceSummary } from "../types";
import { RunView } from "../detail/run/RunView";
@ -41,6 +46,18 @@ interface AgentTracesSectionProps {
readOnly?: boolean;
canMintTracingKey?: boolean;
canViewFindings?: boolean;
onSetUpSignals?: () => void;
}
function useSignalSetup(enabled: boolean) {
const api = useOptionalLensApi();
const config = useQuery({
queryKey: lensKeys.signalConfig(api?.scope ?? ""),
queryFn: api && enabled ? () => api.signalConfig() : skipToken,
staleTime: 5000,
});
const loaded = enabled && config.data !== undefined;
return { on: loaded && signalsConfigured(config.data), missing: loaded && !signalsConfigured(config.data) };
}
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
@ -79,6 +96,7 @@ export function AgentTracesSection({
readOnly = false,
canMintTracingKey = false,
canViewFindings,
onSetUpSignals,
}: AgentTracesSectionProps) {
const live = useTracesLive();
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
@ -109,6 +127,8 @@ export function AgentTracesSection({
const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]);
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
const findings = useTraceFindings(accessToken, runs, isActive, canViewFindings);
const signalSetup = useSignalSetup(isActive && canViewFindings !== false);
const signals = useTraceSignals(accessToken, runs, isActive && signalSetup.on);
const changeRange = (hours: number, apply: (hours: number) => void) => {
setZoom(null);
@ -173,6 +193,7 @@ export function AgentTracesSection({
accessToken={accessToken}
onBack={() => openRun(null)}
embedded
showSignals={signalSetup.on}
/>
)}
</Inspector.Panel>
@ -211,6 +232,10 @@ export function AgentTracesSection({
traces={runs}
findings={findings}
canViewFindings={canViewFindings}
signals={signals}
showSignals={signalSetup.on}
signalsColumn={signalSetup.on || signalSetup.missing}
onSetUpSignals={onSetUpSignals}
isLoading={traces.isLoading || (checkHistory && history.isLoading)}
error={traces.error}
hasMore={traces.hasMore}

View file

@ -254,3 +254,79 @@ describe("AgentTracesTable column picker", () => {
expect(within(placeholder).getAllByRole("cell", { hidden: true })).toHaveLength(columnCount);
});
});
describe("AgentTracesTable signals", () => {
const [flaggedRun, cleanRun, queuedRun] = ((traceList as TracePage).data as TraceSummary[]).slice(0, 3);
const key = (run: TraceSummary) => run.trace_ref || run.trace_id;
const result = (
run: TraceSummary,
status: "classified" | "unclassified",
flags: { signal_id: string; name: string; score: number }[] = [],
) => ({
status: "ready" as const,
signals: {
trace_id: run.trace_id,
trace_ref: run.trace_ref ?? "",
status,
flags,
model: "jev",
classified_at: null,
},
});
const renderTable = (showSignals: boolean, onSetUpSignals?: () => void) =>
renderWithProviders(
inList(
<AgentTracesTable
traces={[flaggedRun, cleanRun, queuedRun]}
findings={new Map()}
signals={
new Map([
[
key(flaggedRun),
result(flaggedRun, "classified", [
{ signal_id: "user_frustration", name: "User frustration", score: 0.92 },
{ signal_id: "repeated_request", name: "Repeated request", score: 0.71 },
]),
],
[key(cleanRun), result(cleanRun, "classified")],
[key(queuedRun), result(queuedRun, "unclassified")],
])
}
showSignals={showSignals}
signalsColumn
onSetUpSignals={onSetUpSignals}
isLoading={false}
error={null}
hasMore={false}
onLoadMore={vi.fn()}
rangeEmpty={false}
onSetUpTracing={vi.fn()}
/>,
),
);
it("flags matching runs in red and names every detected signal", () => {
renderTable(true);
const rows = screen.getAllByTestId("agent-trace-row");
expect(rows.map((row) => row.hasAttribute("data-flagged"))).toEqual([true, false, false]);
const flagged = within(rows[0]).getByRole("list", { name: "Signals" });
expect(
within(flagged)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(["User frustration", "Repeated request"]);
expect(flagged).toHaveAttribute("title", "Signals: User frustration (92%), Repeated request (71%)");
expect(within(rows[1]).getByTitle("No signals detected")).toBeInTheDocument();
expect(within(rows[2]).getByText("Queued")).toBeInTheDocument();
});
it("keeps the signals column with a setup link until signals are configured", async () => {
const onSetUpSignals = vi.fn();
renderTable(false, onSetUpSignals);
const header = screen.getByRole("columnheader", { name: /Signals/ });
await userEvent.click(within(header).getByRole("button", { name: "Set up signals" }));
expect(onSetUpSignals).toHaveBeenCalledOnce();
expect(screen.getAllByTitle("Signals are not set up")).toHaveLength(3);
expect(screen.getAllByTestId("agent-trace-row").some((row) => row.hasAttribute("data-flagged"))).toBe(false);
});
});

View file

@ -1,7 +1,7 @@
"use client";
import { getCoreRowModel, useReactTable, type ColumnDef, type TableOptions } from "@tanstack/react-table";
import { ArrowDown, ChevronRight } from "lucide-react";
import { ArrowDown, ChevronRight, Plus } from "lucide-react";
import { createContext, useContext, useEffect } from "react";
import { useInView } from "react-intersection-observer";
@ -15,6 +15,8 @@ import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation
import { SpanIcon } from "../ui/SpanIcon";
import type { TraceFindingState } from "./useTraceFindings";
import { flaggedSignals, isFlagged, type TraceSignalState } from "./useTraceSignals";
import { SignalPills } from "../ui/SignalPills";
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
import type { TraceSummary } from "../types";
import { traceRefOf } from "../routing";
@ -24,6 +26,10 @@ interface AgentTracesTableProps {
traces: TraceSummary[];
findings: ReadonlyMap<string, TraceFindingState>;
canViewFindings?: boolean;
signals?: ReadonlyMap<string, TraceSignalState>;
showSignals?: boolean;
signalsColumn?: boolean;
onSetUpSignals?: () => void;
isLoading: boolean;
error: Error | null;
hasMore: boolean;
@ -79,6 +85,10 @@ const ROW_HEIGHT = 36;
const MUTED_NUM = "font-mono text-muted-foreground";
const NUM = "font-mono text-foreground";
const FindingsContext = createContext<ReadonlyMap<string, TraceFindingState>>(new Map());
const SignalsContext = createContext<ReadonlyMap<string, TraceSignalState>>(new Map());
const NO_SIGNALS: ReadonlyMap<string, TraceSignalState> = new Map();
const SignalSetupContext = createContext<{ configured: boolean; onSetUp?: () => void }>({ configured: false });
const FLAGGED_ROW = "bg-destructive/[0.04] shadow-[inset_2px_0_0_var(--color-destructive)] hover:bg-destructive/[0.07]";
function AgentCell({ run }: { run: TraceSummary }) {
const framework = traceFramework(run);
@ -106,7 +116,9 @@ function InputCell({ run }: { run: TraceSummary }) {
Partial totals
</span>
)}
<span className="hidden shrink-0 font-mono text-xs text-muted-foreground 2xl:inline">{run.trace_id}</span>
<span className="hidden max-w-32 min-w-0 shrink-[100] truncate font-mono text-xs text-muted-foreground 2xl:inline">
{run.trace_id}
</span>
</div>
);
}
@ -120,6 +132,52 @@ function FindingCount({ run }: { run: TraceSummary }) {
return <span title={`${state.count} findings from completed investigations`}>{state.count.toLocaleString()}</span>;
}
function SignalsHeader() {
const { configured, onSetUp } = useContext(SignalSetupContext);
if (configured || !onSetUp) return <>Signals</>;
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap">
Signals
<Button
type="button"
variant="outline"
size="xs"
aria-label="Set up signals"
onClick={(event) => {
event.stopPropagation();
onSetUp();
}}
className="h-5 rounded-full px-2 font-medium tracking-normal normal-case"
>
<Plus />
Set up
</Button>
</span>
);
}
const mutedCell = (label: string, title: string) => (
<span className="text-muted-foreground" title={title}>
{label}
</span>
);
function SignalsCell({ run }: { run: TraceSummary }) {
const { configured } = useContext(SignalSetupContext);
const state = useContext(SignalsContext).get(runKey(run));
const muted = mutedCell;
if (!configured) return muted("-", "Signals are not set up");
if (!state || state.status === "pending") return <Skeleton aria-label="Loading signals" className="h-3 w-16" />;
if (state.status === "error") return muted("Unavailable", "Could not load signals");
const { status } = state.signals;
if (status === "unclassified") return muted("Queued", "Waiting for the System 1 model to check this run");
if (status === "pending") return muted("Checking", "The System 1 model is checking this run");
if (status === "failed") return muted("Not checked", "The System 1 model could not check this run");
const flags = flaggedSignals(state.signals);
if (!flags.length) return muted("-", "No signals detected");
return <SignalPills flags={flags} className="overflow-hidden" />;
}
const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
{
id: "time",
@ -160,6 +218,13 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
},
},
{ id: "input", header: "Input", cell: ({ row }) => <InputCell run={row.original} /> },
{
id: "signals",
size: 300,
header: () => <SignalsHeader />,
cell: ({ row }) => <SignalsCell run={row.original} />,
meta: { title: "Signals", renderSkeleton: () => <Skeleton className="h-3 w-16" /> },
},
{
id: "agents",
size: 72,
@ -248,6 +313,10 @@ export function AgentTracesTable({
traces,
findings,
canViewFindings = true,
signals = NO_SIGNALS,
showSignals = false,
signalsColumn = showSignals,
onSetUpSignals,
isLoading,
error,
hasMore,
@ -265,7 +334,9 @@ export function AgentTracesTable({
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces");
const tableOptions: TableOptions<TraceSummary> = {
data: traces,
columns: RUN_COLUMNS.filter((column) => canViewFindings || column.id !== "findings"),
columns: RUN_COLUMNS.filter(
(column) => (canViewFindings || column.id !== "findings") && (signalsColumn || column.id !== "signals"),
),
defaultColumn: { size: undefined },
getRowId: runKey,
autoResetAll: false,
@ -276,56 +347,64 @@ export function AgentTracesTable({
const table = useReactTable(tableOptions);
return (
<FindingsContext.Provider value={findings}>
<InspectorTable.Root table={table} data-testid="runs-table">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
className={bodyClassName(isPlaceholder)}
rowHeight={() => ROW_HEIGHT}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
</>
}
>
{(row) => (
<InspectorTable.Row
row={row}
item={traceRefOf(row.original)}
data-testid="agent-trace-row"
className="h-9"
/>
<SignalsContext.Provider value={signals}>
<SignalSetupContext.Provider value={{ configured: showSignals, onSetUp: onSetUpSignals }}>
<InspectorTable.Root table={table} data-testid="runs-table">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
className={bodyClassName(isPlaceholder)}
rowHeight={() => ROW_HEIGHT}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
</>
}
>
{(row) => {
const flagged = showSignals && isFlagged(signals.get(runKey(row.original)));
return (
<InspectorTable.Row
row={row}
item={traceRefOf(row.original)}
data-testid="agent-trace-row"
data-flagged={flagged || undefined}
className={cn("h-9", flagged && FLAGGED_ROW)}
/>
);
}}
</InspectorTable.Body>
</InspectorTable.Grid>
{isLoading && (
<p role="status" className="sr-only">
Loading runs…
</p>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
{isLoading && (
<p role="status" className="sr-only">
Loading runs…
</p>
)}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
{onRetry && (
<Button size="xs" variant="outline" disabled={isFetching} onClick={onRetry}>
Retry
</Button>
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
{onRetry && (
<Button size="xs" variant="outline" disabled={isFetching} onClick={onRetry}>
Retry
</Button>
)}
</div>
)}
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</InspectorTable.Root>
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</InspectorTable.Root>
</SignalSetupContext.Provider>
</SignalsContext.Provider>
</FindingsContext.Provider>
);
}

View file

@ -0,0 +1,68 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { chunk } from "es-toolkit";
import { useTracesApi } from "../api";
import type { SignalFlag, TraceSignals, TraceSummary } from "../types";
export type TraceSignalState = { status: "ready"; signals: TraceSignals } | { status: "pending" } | { status: "error" };
const POLL_MS = 15000;
const identity = ({ trace_id, trace_ref }: { trace_id: string; trace_ref?: string | null }) => ({
trace_id,
trace_ref: trace_ref ?? "",
});
export const flaggedSignals = (signals?: TraceSignals): SignalFlag[] =>
signals?.status === "classified" ? signals.flags ?? [] : [];
export const isFlagged = (state?: TraceSignalState): boolean =>
state?.status === "ready" && flaggedSignals(state.signals).length > 0;
export function useTraceSignals(accessToken: string, runs: TraceSummary[], enabled: boolean) {
const api = useTracesApi(accessToken);
const batches = chunk(runs.map(identity), 500);
const queries = useQueries({
queries: batches.map((traces) => ({
queryKey: ["traceSignals", accessToken, traces],
queryFn: () => api.signals(traces),
enabled,
staleTime: POLL_MS,
refetchInterval: enabled && api.live ? POLL_MS : false,
retry: false,
})),
});
return new Map<string, TraceSignalState>(
batches.flatMap((traces, index) => {
const query = queries[index];
const results = new Map(query.data?.map((result) => [result.trace_ref || result.trace_id, result]));
return traces.map((trace): [string, TraceSignalState] => {
const key = trace.trace_ref || trace.trace_id;
const found = results.get(key);
if (query.isError) return [key, { status: "error" }];
if (query.isPending) return [key, { status: "pending" }];
if (!found) return [key, { status: "error" }];
return [key, { status: "ready", signals: found }];
});
}),
);
}
export function useTraceSignalFlags(
accessToken: string,
trace: { trace_id: string; trace_ref?: string | null },
enabled: boolean,
): SignalFlag[] {
const api = useTracesApi(accessToken);
const traces = [identity(trace)];
const queryOptions = {
queryKey: ["traceSignals", accessToken, traces],
queryFn: () => api.signals(traces),
enabled,
staleTime: POLL_MS,
refetchInterval: enabled && api.live ? POLL_MS : (false as const),
retry: false,
};
const query = useQuery<TraceSignals[]>(queryOptions);
return enabled ? flaggedSignals(query.data?.[0]) : [];
}

View file

@ -13,6 +13,8 @@ export type SpanErrorQuery = NonNullable<
export type TraceQueryBody = components["schemas"]["TraceQueryRequest"];
export type TraceFindingsRequest = components["schemas"]["TraceFindingsRequest"];
export type TraceFindingCount = components["schemas"]["TraceFindingCount"];
export type TraceSignals = components["schemas"]["TraceSignals"];
export type SignalFlag = NonNullable<TraceSignals["flags"]>[number];
type ApiSpanDetail =
paths["/v1/traces/{trace_id}/spans/{span_id}"]["get"]["responses"][200]["content"]["application/json"];
export type Span = Trace["spans"][number];

View file

@ -0,0 +1,43 @@
import { cn } from "@/lib/cva.config";
import type { SignalFlag } from "../types";
const percent = (score: number): string => `${Math.round(score * 100)}%`;
export const signalSummary = (flags: readonly SignalFlag[]): string =>
flags.map((flag) => `${flag.name} (${percent(flag.score)})`).join(", ");
function SignalPill({ flag, showScore }: { flag: SignalFlag; showScore: boolean }) {
return (
<span className="inline-flex max-w-full min-w-0 items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-xs leading-none font-medium text-destructive">
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-destructive" />
<span className="truncate">{flag.name}</span>
{showScore && <span className="shrink-0 font-normal tabular-nums opacity-80">{percent(flag.score)}</span>}
</span>
);
}
export function SignalPills({
flags,
showScore = false,
className,
}: {
flags: readonly SignalFlag[];
showScore?: boolean;
className?: string;
}) {
return (
<span
role="list"
aria-label="Signals"
title={`Signals: ${signalSummary(flags)}`}
className={cn("inline-flex max-w-full min-w-0 items-center gap-1", className)}
>
{flags.map((flag) => (
<span role="listitem" key={flag.signal_id} className="min-w-0">
<SignalPill flag={flag} showScore={showScore} />
</span>
))}
</span>
);
}

View file

@ -9103,6 +9103,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/signals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Signals */
get: operations["get_signals_lens_signals_get"];
/** Put Signals */
put: operations["put_signals_lens_signals_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/traces/findings": {
parameters: {
query?: never;
@ -9120,6 +9138,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/traces/signals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Trace Signal Statuses */
post: operations["trace_signal_statuses_lens_traces_signals_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/watch-all": {
parameters: {
query?: never;
@ -45477,6 +45512,58 @@ export interface components {
/** Type */
type: "shell" | string;
};
/** Signal */
Signal: {
/** Id */
id: string;
/** Name */
name: string;
/** Question */
question: string;
};
/** SignalConfig */
SignalConfig: {
/**
* Model
* @default
*/
model: string;
/**
* Signals
* @default [
* {
* "id": "user_frustration",
* "name": "User frustration",
* "question": "Does the user show frustration, annoyance or dissatisfaction with the agent in this run, for example complaints, irritated corrections, all caps, profanity, or giving up on the task?"
* },
* {
* "id": "missing_capability",
* "name": "Missing capability",
* "question": "Does the user ask for something the agent cannot do in this run, so that the agent refuses, says it lacks a tool, permission, integration or data source, or fails because the capability does not exist?"
* },
* {
* "id": "repeated_request",
* "name": "Repeated request",
* "question": "Does the user ask for the same thing more than once in this run, usually because the agent did not deliver it the first time?"
* }
* ]
*/
signals: components["schemas"]["Signal"][];
/**
* Threshold
* @default 0.5
*/
threshold: number;
};
/** SignalFlag */
SignalFlag: {
/** Name */
name: string;
/** Score */
score: number;
/** Signal Id */
signal_id: string;
};
/**
* Skill
* @description Represents a skill from the Anthropic Skills API
@ -47719,6 +47806,33 @@ export interface components {
[key: string]: components["schemas"]["JsonValue"];
}[];
};
/** TraceSignals */
TraceSignals: {
/** Classified At */
classified_at?: string | null;
/**
* Flags
* @default []
*/
flags: components["schemas"]["SignalFlag"][];
/**
* Model
* @default
*/
model: string;
/**
* Status
* @enum {string}
*/
status: "unclassified" | "pending" | "classified" | "failed";
/** Trace Id */
trace_id: string;
/**
* Trace Ref
* @default
*/
trace_ref: string;
};
/** TraceSource */
TraceSource: {
/**
@ -63081,6 +63195,59 @@ export interface operations {
};
};
};
get_signals_lens_signals_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SignalConfig"];
};
};
};
};
put_signals_lens_signals_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SignalConfig"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SignalConfig"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
trace_findings_lens_traces_findings_post: {
parameters: {
query?: never;
@ -63114,6 +63281,39 @@ export interface operations {
};
};
};
trace_signal_statuses_lens_traces_signals_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TraceFindingsRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TraceSignals"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
watch_all_lens_watch_all_post: {
parameters: {
query?: never;