diff --git a/application/agents/agentic_agent.py b/application/agents/agentic_agent.py index 3c213ef5..b4527a05 100644 --- a/application/agents/agentic_agent.py +++ b/application/agents/agentic_agent.py @@ -2,10 +2,7 @@ import logging from typing import Dict, Generator, Optional from application.agents.base import BaseAgent -from application.agents.tools.internal_search import ( - INTERNAL_TOOL_ID, - add_internal_search_tool, -) +from application.agents.tools.internal_search import add_internal_search_tool from application.agents.tools.wiki import add_wiki_tool from application.logging import LogContext @@ -59,26 +56,3 @@ class AgenticAgent(BaseAgent): log_context.stacks.append( {"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}} ) - - def _collect_internal_sources(self): - """Merge the cached InternalSearchTool's docs into ``retrieved_docs``, - deduped, preserving any pre-fetched docs so a mixed-exposure agent cites - both pre-fetched and tool-retrieved sources (not just the tool's).""" - cache_key = f"internal_search:{INTERNAL_TOOL_ID}:{self.user or ''}" - tool = self.tool_executor._loaded_tools.get(cache_key) - if not (tool and getattr(tool, "retrieved_docs", None)): - return - - def _key(d): - if isinstance(d, dict): - return (d.get("source"), d.get("title"), d.get("text")) - return id(d) - - merged = list(self.retrieved_docs or []) - seen = {_key(d) for d in merged} - for doc in tool.retrieved_docs: - k = _key(doc) - if k not in seen: - seen.add(k) - merged.append(doc) - self.retrieved_docs = merged diff --git a/application/agents/base.py b/application/agents/base.py index 0eddaebe..4b1e63bb 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -24,7 +24,7 @@ from application.guardrails.runtime import ( resolve_config as resolve_guardrails_config, ) from application.guardrails.stream import StreamingOutputGuard -from application.guardrails.types import Stage +from application.guardrails.types import Action, Stage, resolve_tool_result from application.llm.handlers.handler_creator import LLMHandlerCreator from application.llm.llm_creator import LLMCreator from application.logging import build_stack_data, log_activity, LogContext @@ -189,22 +189,21 @@ class BaseAgent(ABC): engine = self.guardrails if engine is None or not engine.has_stage(stage): return None - # _build_document_block runs once per document shed by the token - # budget, so an over-budget turn would otherwise pay for N identical - # scans and write N sets of duplicate audit rows. - cache_key = (stage, hash(text)) + # The same text is scanned twice in two places: the route runs the + # input stage before it persists the question and ``gen`` runs it + # again, and the token-shed loop rebuilds the document block. Keyed on + # the text itself rather than its hash — a decision carries the + # redacted text, so serving one for a colliding key would substitute + # the wrong turn's output. + cache_key = (stage, text) cache = getattr(self, "_guardrail_cache", None) if cache is None: cache = self._guardrail_cache = {} cached = cache.get(cache_key) if cached is not None: return cached - # Keep the scan context pointed at this turn's documents; retrieval can - # land after the engine was built. - engine.context.retrieved_docs = getattr(self, "retrieved_docs", None) or [] decision = engine.evaluate(text, stage) - if stage is Stage.RETRIEVAL: - cache[cache_key] = decision + cache[cache_key] = decision return decision def bind_guardrail_log_context(self, log_context) -> None: @@ -224,15 +223,22 @@ class BaseAgent(ABC): """Apply tool-result controls to a string, returning what may be used.""" if not isinstance(text, str) or not text: return text - decision = self._guardrail_stage(text, Stage.TOOL_RESULT) - if decision is None: - return text - if decision.blocked: - return ( - "[Tool result withheld by a content policy. Tell the user the " - "result could not be used; do not speculate about its contents.]" - ) - return decision.text if decision.redacted else text + return resolve_tool_result( + text, self._guardrail_stage(text, Stage.TOOL_RESULT) + ) + + def bind_guardrail_message_id(self, message_id: Optional[str]) -> None: + """Tell the recorder which message its rows belong to. + + Set as soon as the row is reserved, so a flush that happens before the + caller's own flush — an input block returns from ``gen`` immediately — + still lands linked instead of orphaned with a NULL ``message_id``. + """ + if not message_id: + return + recorder = getattr(self.guardrails, "recorder", None) + if recorder is not None: + recorder.message_id = message_id def flush_guardrail_audit(self, message_id: Optional[str] = None) -> None: engine = self._guardrail_engine @@ -240,6 +246,19 @@ class BaseAgent(ABC): if recorder is not None and hasattr(recorder, "flush"): recorder.flush(message_id) + def apply_input_guardrails(self, query: str): + """Run input controls once and return ``(query, decision)``. + + The query comes back redacted when a redact control fired, so callers + that persist or log the question store what the control produced + rather than the raw text. ``gen`` and the route both call this; the + stage cache makes the second call free. + """ + decision = self._guardrail_stage(query, Stage.INPUT) + if decision is not None and decision.redacted: + return decision.text, decision + return query, decision + @staticmethod def _guardrail_block_event(decision, message: str) -> Dict: """The terminal payload for a blocked turn. @@ -263,16 +282,13 @@ class BaseAgent(ABC): self, query: str, log_context: LogContext = None ) -> Generator[Dict, None, None]: self.bind_guardrail_log_context(log_context) - decision = self._guardrail_stage(query, Stage.INPUT) - if decision is not None: - if decision.blocked: - yield self._guardrail_block_event( - decision, decision.block_message or GUARDRAIL_DEFAULT_MESSAGE - ) - self.flush_guardrail_audit() - return - if decision.redacted: - query = decision.text + query, decision = self.apply_input_guardrails(query) + if decision is not None and decision.blocked: + yield self._guardrail_block_event( + decision, decision.block_message or GUARDRAIL_DEFAULT_MESSAGE + ) + self.flush_guardrail_audit() + return yield from self._gen_inner(query, log_context) yield from self._emit_responses_metadata() @@ -762,6 +778,46 @@ class BaseAgent(ABC): formatted = decision.text return f"\n{formatted}\n\n{self.DOCUMENT_GUARD}" + def _guard_embedded_documents(self, system_prompt: str) -> str: + """Scan documents that a custom prompt interpolates itself. + + ``_build_document_block`` returns early for these agents because the + rendered prompt already carries the documents, which left retrieval + controls scanning nothing at all — and text inside the system prompt + arrives with system authority, the worst place for unscanned, + attacker-influenceable material. The prompt was rendered before the + agent ran, so the verdict is applied by patching it here. + """ + from application.api.answer.services.prompt_renderer import ( + format_docs_for_prompt, + ) + + formatted = format_docs_for_prompt(getattr(self, "retrieved_docs", None)) + if not formatted: + return system_prompt + decision = self._guardrail_stage(formatted, Stage.RETRIEVAL) + if decision is None or not (decision.blocked or decision.redacted): + return system_prompt + self._apply_retrieval_decision(decision) + if formatted in system_prompt: + replacement = ( + self.RETRIEVAL_BLOCKED_NOTE if decision.blocked else decision.text + ) + return system_prompt.replace(formatted, replacement) + # The template placed the documents somewhere this cannot reach. Fail + # the turn rather than send the model text a control just rejected. + if decision.blocked: + raise ValueError( + "Retrieved sources were withheld by a content policy and this " + "agent's prompt embeds them directly, so the request cannot be " + "completed." + ) + logger.warning( + "Retrieval redaction could not be applied to an embedding prompt; " + "the sources shown to the user were scrubbed but the prompt was not" + ) + return system_prompt + def _apply_retrieval_decision(self, decision) -> None: """Mirror a retrieval verdict onto the documents the client will see.""" docs = getattr(self, "retrieved_docs", None) or [] @@ -776,17 +832,69 @@ class BaseAgent(ABC): engine = self.guardrails if engine is None: return + # Only a redacting control can change a document, and a remote judge + # cannot redact at all — it reports no spans. Narrowing the per-document + # pass is what keeps an 8-chunk retrieval from turning one stage + # evaluation into nine, each with its own judge call. + redacting = [ + control + for control in engine.config.controls_for(Stage.RETRIEVAL) + if control.action is Action.REDACT + ] + if not redacting: + return scrubbed = [] for doc in docs: if not isinstance(doc, dict) or not doc.get("text"): scrubbed.append(doc) continue - per_doc = engine.evaluate(str(doc["text"]), Stage.RETRIEVAL) + per_doc = engine.evaluate( + str(doc["text"]), Stage.RETRIEVAL, controls=redacting + ) scrubbed.append( {**doc, "text": per_doc.text} if per_doc.redacted else doc ) self.retrieved_docs = scrubbed + def _collect_internal_sources(self) -> None: + """Merge the cached InternalSearchTool's docs into ``retrieved_docs``, + deduped, preserving any pre-fetched docs so a mixed-exposure agent cites + both pre-fetched and tool-retrieved sources (not just the tool's).""" + from application.agents.tools.internal_search import INTERNAL_TOOL_ID + + executor = getattr(self, "tool_executor", None) + loaded = getattr(executor, "_loaded_tools", None) or {} + tool = loaded.get(f"internal_search:{INTERNAL_TOOL_ID}:{self.user or ''}") + if not (tool and getattr(tool, "retrieved_docs", None)): + return + + def _key(d): + if isinstance(d, dict): + return (d.get("source"), d.get("title"), d.get("text")) + return id(d) + + merged = list(self.retrieved_docs or []) + seen = {_key(d) for d in merged} + for doc in tool.retrieved_docs: + k = _key(doc) + if k not in seen: + seen.add(k) + merged.append(doc) + self.retrieved_docs = merged + + def _refresh_sources_before_output(self) -> None: + """Pull tool-retrieved documents in before output controls run. + + ``internal_search`` results land when the tool loop finishes, which is + after the answer starts streaming. Groundedness judges the answer + against ``retrieved_docs``, so without this it sees an empty list and + reports every tool-retrieved answer as unsourced. + """ + try: + self._collect_internal_sources() + except Exception: + logger.debug("Could not refresh sources before output guarding") + def _compose_user_turn(self, document_block: str, query: str) -> str: """Combine the document block and the question into one user message.""" return f"{document_block}\n\n{query}" if document_block else query @@ -800,6 +908,12 @@ class BaseAgent(ABC): from application.core.model_utils import get_token_limit from application.utils import num_tokens_from_string + # Retrieval controls run inside _build_document_block for the usual + # path; a prompt that embeds the documents skips that block entirely, + # so its scan happens here instead. + if getattr(self, "prompt_embeds_documents", False): + system_prompt = self._guard_embedded_documents(system_prompt) + if self.compressed_summary: compression_context = ( "\n\n---\n\n" @@ -1128,9 +1242,13 @@ class BaseAgent(ABC): guarding = engine is not None and engine.has_stage(Stage.OUTPUT) if isinstance(response, str): + if guarding: + self._refresh_sources_before_output() yield from self._guarded_complete_answer(response, answer_event) return if hasattr(response, "message") and getattr(response.message, "content", None): + if guarding: + self._refresh_sources_before_output() yield from self._guarded_complete_answer(response.message.content, answer_event) return @@ -1164,6 +1282,7 @@ class BaseAgent(ABC): buffered.append(text) elif isinstance(event, dict) and "type" in event: yield event + self._refresh_sources_before_output() yield from self._guarded_complete_answer("".join(buffered), answer_event) return @@ -1182,6 +1301,10 @@ class BaseAgent(ABC): step.decisions[-1], step.block_message or GUARDRAIL_DEFAULT_MESSAGE ) return + # The tool loop has finished by the time the generator is exhausted, so + # this is the last point at which the deferred checks in ``flush`` can + # still be given the documents the answer was actually built from. + self._refresh_sources_before_output() step = guard.flush() if step.emit: yield answer_event(step.emit) diff --git a/application/agents/classic_agent.py b/application/agents/classic_agent.py index c1421f13..e8eb8d85 100644 --- a/application/agents/classic_agent.py +++ b/application/agents/classic_agent.py @@ -2,10 +2,7 @@ import logging from typing import Dict, Generator, Optional from application.agents.base import BaseAgent -from application.agents.tools.internal_search import ( - INTERNAL_TOOL_ID, - add_internal_search_tool, -) +from application.agents.tools.internal_search import add_internal_search_tool from application.agents.tools.wiki import add_wiki_tool from application.logging import LogContext @@ -61,26 +58,3 @@ class ClassicAgent(BaseAgent): log_context.stacks.append( {"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}} ) - - def _collect_internal_sources(self): - """Merge the cached InternalSearchTool's docs into ``retrieved_docs``, - deduped, preserving any pre-fetched docs so a mixed-exposure agent cites - both pre-fetched and tool-retrieved sources (not just the tool's).""" - cache_key = f"internal_search:{INTERNAL_TOOL_ID}:{self.user or ''}" - tool = self.tool_executor._loaded_tools.get(cache_key) - if not (tool and getattr(tool, "retrieved_docs", None)): - return - - def _key(d): - if isinstance(d, dict): - return (d.get("source"), d.get("title"), d.get("text")) - return id(d) - - merged = list(self.retrieved_docs or []) - seen = {_key(d) for d in merged} - for doc in tool.retrieved_docs: - k = _key(doc) - if k not in seen: - seen.add(k) - merged.append(doc) - self.retrieved_docs = merged diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index 3a07497a..c84f37d9 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -13,7 +13,7 @@ from application.agents.default_tools import ( ) from application.agents.tools.tool_action_parser import ToolActionParser from application.agents.tools.tool_manager import ToolManager -from application.guardrails.types import Stage as GuardrailStage +from application.guardrails.types import Stage as GuardrailStage, resolve_tool_result from application.security.encryption import decrypt_credentials from application.storage.db.base_repository import looks_like_uuid from application.storage.db.repositories.agents import AgentsRepository @@ -621,12 +621,7 @@ class ToolExecutor: except Exception: logger.exception("Tool-result guardrail failed for %s.%s", tool_name, action_name) return result - if decision.blocked: - return ( - "[Tool result withheld by a content policy. Tell the user the " - "result could not be used; do not speculate about its contents.]" - ) - return decision.text if decision.redacted else result + return resolve_tool_result(result, decision) def check_pause(self, tools_dict: Dict, call, llm_class_name: str) -> Optional[Dict]: """Return a pending-action dict (approval / client / headless_denied) or None. diff --git a/application/agents/workflow_agent.py b/application/agents/workflow_agent.py index b807fb09..20b1c6e5 100644 --- a/application/agents/workflow_agent.py +++ b/application/agents/workflow_agent.py @@ -6,7 +6,6 @@ from application.agents.base import BaseAgent from application.guardrails.config import ( DEFAULT_BLOCK_MESSAGE as GUARDRAIL_DEFAULT_MESSAGE, ) -from application.guardrails.types import Stage from application.agents.workflows.schemas import ( ExecutionStatus, Workflow, @@ -60,16 +59,13 @@ class WorkflowAgent(BaseAgent): # here or a workflow agent would accept guardrail config in the builder # and silently enforce none of it. self.bind_guardrail_log_context(log_context) - decision = self._guardrail_stage(query, Stage.INPUT) - if decision is not None: - if decision.blocked: - yield self._guardrail_block_event( - decision, decision.block_message or GUARDRAIL_DEFAULT_MESSAGE - ) - self.flush_guardrail_audit() - return - if decision.redacted: - query = decision.text + query, decision = self.apply_input_guardrails(query) + if decision is not None and decision.blocked: + yield self._guardrail_block_event( + decision, decision.block_message or GUARDRAIL_DEFAULT_MESSAGE + ) + self.flush_guardrail_audit() + return yield from self._gen_inner(query, log_context) def _gen_inner(self, query: str, log_context: LogContext) -> Generator[Dict[str, str], None, None]: diff --git a/application/agents/workflows/workflow_engine.py b/application/agents/workflows/workflow_engine.py index 93791526..d19b2053 100644 --- a/application/agents/workflows/workflow_engine.py +++ b/application/agents/workflows/workflow_engine.py @@ -369,6 +369,11 @@ class WorkflowEngine: if doc_manifest: node_prompt = f"{node_prompt}\n\n{doc_manifest}" if node_prompt else doc_manifest + # No ``agent_config`` here, deliberately: per-agent guardrails are not + # wired through workflows yet, so a node runs the instance floor (which + # ``resolve_config(None)`` still applies) but none of the parent + # agent's own controls. Passing it would need an aggregate output guard + # in WorkflowAgent to be meaningful. factory_kwargs = { "agent_type": node_config.agent_type, "endpoint": self.agent.endpoint, diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py index 8fa3e2e2..63aee214 100644 --- a/application/api/answer/routes/base.py +++ b/application/api/answer/routes/base.py @@ -301,6 +301,23 @@ class BaseAnswerResource: # Intentional: a continuation round reserves no new WAL row, so on the # stateless ``/v1`` path the intermediate tool rounds aren't persisted # (only the first turn + the final answer turn are). Accepted as-is. + # Input controls have to run before the question is stored, not only + # inside ``gen``: this frame is what writes ``conversation_messages`` + # and ``user_logs``, so a redaction that reached the model prompt alone + # would still leave the raw text — the PII the control exists to keep + # out of storage — in both. ``gen`` re-runs the stage against the + # original question and hits the agent's stage cache, so the scan is + # paid for once. + raw_question = question + guard_input = getattr(agent, "apply_input_guardrails", None) + if callable(guard_input) and not _continuation: + try: + question, _ = guard_input(question) + except Exception: + logger.exception( + "Input guardrail scan failed; persisting the question unredacted" + ) + wal_eligible = should_persist and not _continuation if wal_eligible: try: @@ -328,6 +345,16 @@ class BaseAnswerResource: elif _continuation and _continuation.get("reserved_message_id"): reserved_message_id = _continuation["reserved_message_id"] + # Bind the row now so an audit flush that happens before the ``finally`` + # below — an input block returns from ``gen`` immediately and flushes + # there — still writes rows linked to their message instead of orphans. + bind_message_id = getattr(agent, "bind_guardrail_message_id", None) + if callable(bind_message_id): + try: + bind_message_id(reserved_message_id) + except Exception: + logger.exception("Could not bind guardrail audit to the message row") + primary_llm = getattr(agent, "llm", None) if primary_llm is not None: primary_llm._request_id = request_id @@ -572,7 +599,11 @@ class BaseAnswerResource: reasoning_content=_continuation.get("reasoning_content", ""), ) else: - gen_iter = agent.gen(query=question) + # The original text: ``gen`` runs the input stage itself and + # applies the redaction to what it sends the model. Handing it + # the already-redacted question would make that a second scan + # over different text, and a remote check would be paid twice. + gen_iter = agent.gen(query=raw_question) # Seed a liveness heartbeat the moment generation starts, before # the first chunk. The row is still ``pending`` here; this stamps a diff --git a/application/guardrails/base.py b/application/guardrails/base.py index 9faceb05..5caa4692 100644 --- a/application/guardrails/base.py +++ b/application/guardrails/base.py @@ -15,6 +15,7 @@ class ScanContext: self, query: Optional[str] = None, retrieved_docs: Optional[list] = None, + docs_provider=None, tool_name: Optional[str] = None, action_name: Optional[str] = None, tool_args: Optional[Dict[str, Any]] = None, @@ -23,7 +24,14 @@ class ScanContext: user: Optional[str] = None, ): self.query = query - self.retrieved_docs = retrieved_docs or [] + # Retrieval can land after the engine is built, and an agent rebinds + # ``retrieved_docs`` rather than mutating it (tool collection, token + # shedding, redaction), so a snapshot taken here goes stale. Reading + # through a provider keeps every stage — including the streaming + # output guard, which does not go through ``_guardrail_stage`` — on + # this turn's actual documents. + self._docs_provider = docs_provider + self._retrieved_docs = retrieved_docs or [] self.tool_name = tool_name self.action_name = action_name self.tool_args = tool_args or {} @@ -31,6 +39,22 @@ class ScanContext: self.agent_id = agent_id self.user = user + @property + def retrieved_docs(self) -> list: + if self._docs_provider is not None: + try: + return self._docs_provider() or [] + except Exception: + return [] + return self._retrieved_docs + + @retrieved_docs.setter + def retrieved_docs(self, value: Optional[list]) -> None: + # An explicit assignment is authoritative: it replaces the provider so + # a caller that pins documents is not silently overridden by the agent. + self._docs_provider = None + self._retrieved_docs = value or [] + class GuardrailCheck(ABC): """A detector. Stateless per scan; constructed once per control.""" diff --git a/application/guardrails/runtime.py b/application/guardrails/runtime.py index d2101dd5..8e1e9dac 100644 --- a/application/guardrails/runtime.py +++ b/application/guardrails/runtime.py @@ -253,7 +253,7 @@ def build_engine(agent, log_context=None) -> Optional[GuardrailEngine]: fail_open=config.fail_open, ) context = ScanContext( - retrieved_docs=getattr(agent, "retrieved_docs", None), + docs_provider=lambda: getattr(agent, "retrieved_docs", None), llm_factory=_judge_factory(agent), agent_id=str(agent.agent_id) if getattr(agent, "agent_id", None) else None, user=getattr(agent, "user", None), diff --git a/application/guardrails/stream.py b/application/guardrails/stream.py index 744505ba..5a2fe98c 100644 --- a/application/guardrails/stream.py +++ b/application/guardrails/stream.py @@ -73,13 +73,21 @@ class StreamingOutputGuard: self._incremental = [c for c in controls if not self._complete_only(c)] self._deferred = [c for c in controls if self._complete_only(c)] self._has_remote = any(self._attr(c, "remote", False) for c in self._incremental) + # monitor_only degrades every action to FLAG, so no verdict reached + # here can change a byte of the answer. Withholding output to wait for + # one would buy nothing and cost the user a frozen stream — with a + # remote control, the whole answer when it is shorter than a segment. + # Pass the stream through untouched and scan the finished answer once, + # purely for the audit trail. + self._monitor_only = engine.config.mode != "scan_all" # The window must cover the longest match any active check can report, # or that check silently stops working the moment output is streamed. self.lookback = ( max(0, lookback) if lookback is not None else self._required_window() ) - # Complete-text checks need the finished answer, not the tail. - self._full = "" if self._deferred else None + # Complete-text checks need the finished answer, not the tail; so does + # the monitor_only end-of-stream scan. + self._full = "" if (self._deferred or self._monitor_only) else None @staticmethod def _attr(control, name: str, default): @@ -127,6 +135,9 @@ class StreamingOutputGuard: return StreamChunk() if not self.active: return StreamChunk(emit=text) + if self._monitor_only: + self._full += text + return StreamChunk(emit=text) combined = self._held + text emit_end = self._release_point(combined) @@ -145,6 +156,8 @@ class StreamingOutputGuard: if not self.active: tail, self._held = self._held, "" return StreamChunk(emit=tail) + if self._monitor_only: + return self._monitor_scan() step = ( self._scan_and_split(self._held, force=True, final=True) if self._held @@ -154,6 +167,20 @@ class StreamingOutputGuard: return step return self._run_deferred(step) + def _monitor_scan(self) -> StreamChunk: + """Scan the finished answer once, for the journal only. + + One scan of the whole answer rather than the per-chunk sweep the + enforcing path runs: the verdict cannot act, so the only thing that + matters is that it is recorded. + """ + controls = self._incremental + self._deferred + if not controls or not self._full: + return StreamChunk() + decision = self.engine.evaluate(self._full, Stage.OUTPUT, controls=controls) + self.decisions.append(decision) + return StreamChunk(decisions=[decision]) + def _run_deferred(self, step: StreamChunk) -> StreamChunk: """Run complete-text checks over the finished answer. diff --git a/application/guardrails/types.py b/application/guardrails/types.py index 13832729..04330880 100644 --- a/application/guardrails/types.py +++ b/application/guardrails/types.py @@ -157,6 +157,25 @@ class StageDecision: } +TOOL_RESULT_BLOCKED_NOTE = ( + "[Tool result withheld by a content policy. Tell the user the " + "result could not be used; do not speculate about its contents.]" +) + + +def resolve_tool_result(result: str, decision: Optional[StageDecision]) -> str: + """Reduce a tool-result verdict to the text the caller may use. + + Shared by the executor path and the client-resume path so a result that + was blocked server-side and one blocked on resume read identically. + """ + if decision is None: + return result + if decision.blocked: + return TOOL_RESULT_BLOCKED_NOTE + return decision.text if decision.redacted else result + + def apply_spans(text: str, spans: List[Span]) -> str: """Replace ``spans`` in ``text``, right-to-left so offsets stay valid. diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 8467b0a7..ab40f21c 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -1471,7 +1471,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setAgent({ ...agent, diff --git a/frontend/src/agents/components/GuardrailsSection.tsx b/frontend/src/agents/components/GuardrailsSection.tsx index c336b3d6..fd03e154 100644 --- a/frontend/src/agents/components/GuardrailsSection.tsx +++ b/frontend/src/agents/components/GuardrailsSection.tsx @@ -88,6 +88,8 @@ type Props = { onChange: (next: GuardrailsConfig) => void; token: string | null; disabled?: boolean; + /** Why the controls are read-only, shown in place of a silent lockout. */ + disabledNotice?: string; }; export default function GuardrailsSection({ @@ -95,6 +97,7 @@ export default function GuardrailsSection({ onChange, token, disabled = false, + disabledNotice, }: Props) { const { t } = useTranslation(); const [expanded, setExpanded] = React.useState(false); @@ -255,6 +258,15 @@ export default function GuardrailsSection({

{loadError}

)} + {disabled && disabledNotice && ( +

+ {disabledNotice} +

+ )} + {instanceDisabled && (