@@ -313,9 +358,25 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f
className="mb-1 rounded-lg border border-purple-500/20 bg-purple-500/5 p-2"
/>
)}
- {tasks.map((task, i) => (
-
- ))}
+ {!showInternalSteps && toolPairs.length > 0 ? (
+ // End-user detail: the flat list of tool calls, plus any failed
+ // step (its error is actionable). The "1 step → langchain →
+ // expand again" task shell said nothing a user could act on.
+ <>
+ {tasks
+ .filter((task) => task.status === "error")
+ .map((task, i) => (
+
+ ))}
+ {toolPairs.map((pair, i) => (
+
+ ))}
+ >
+ ) : (
+ tasks.map((task, i) => (
+
+ ))
+ )}
diff --git a/src/components/chat/chat-drawer.tsx b/src/components/chat/chat-drawer.tsx
index 0f4bda0c..eb5d01a2 100644
--- a/src/components/chat/chat-drawer.tsx
+++ b/src/components/chat/chat-drawer.tsx
@@ -82,6 +82,7 @@ export function ChatDrawer() {
const isProcessing = useChatStore((s) => s.isProcessing);
const isThinking = useChatStore((s) => s.isThinking);
const currentTurnEvents = useDebugStore((s) => s.currentTurnEvents);
+ const liveToolCalls = useDebugStore((s) => s.liveToolCalls);
const startConversation = useStartConversation();
@@ -242,8 +243,13 @@ export function ChatDrawer() {
{/* Live status — the same "Thinking…" / "Using {tool}…"
line the operator and main chat show; the plain
indicator covers the gap before the first event. */}
- {(isProcessing || isThinking) && currentTurnEvents.length > 0 ? (
-
+ {(isProcessing || isThinking) && (currentTurnEvents.length > 0 || liveToolCalls.length > 0) ? (
+
) : isThinking ? (
diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx
index 5dcf2434..f7ef94d9 100644
--- a/src/components/chat/chat-panel.tsx
+++ b/src/components/chat/chat-panel.tsx
@@ -92,6 +92,7 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) {
const showActivity = useDebugStore((s) => s.showActivity);
const toggleShowActivity = useDebugStore((s) => s.toggleShowActivity);
const currentTurnEvents = useDebugStore((s) => s.currentTurnEvents);
+ const liveToolCalls = useDebugStore((s) => s.liveToolCalls);
// Queries & mutations
const { data: deployedAgents, isLoading: agentsLoading } = useDeployedAgents();
@@ -509,8 +510,13 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) {
turn's live events. The dots indicator covers only the gap
before the first event arrives. */}
{(isProcessing || isThinking) &&
- (currentTurnEvents.length > 0 ? (
-
+ (currentTurnEvents.length > 0 || liveToolCalls.length > 0 ? (
+
) : (
))}
diff --git a/src/components/groups/__tests__/format-markdown-text.test.ts b/src/components/groups/__tests__/format-markdown-text.test.ts
index b8f32906..86e079ac 100644
--- a/src/components/groups/__tests__/format-markdown-text.test.ts
+++ b/src/components/groups/__tests__/format-markdown-text.test.ts
@@ -156,6 +156,21 @@ describe("formatMarkdownText", () => {
expect(formatMarkdownText("** von der Strategie **")).toBe("**von der Strategie**");
});
+ // The operator's group-overview reply rendered literal asterisks all over
+ // a table — every cell was `**Name **` (trailing space inside the closing
+ // delimiter, which CommonMark refuses to parse as emphasis).
+ it("repairs the trailing-space bold inside a table cell without eating the cell separator", () => {
+ expect(formatMarkdownText("| **SMC Recruitment Panel ** | PEER_REVIEW |")).toBe(
+ "| **SMC Recruitment Panel** | PEER_REVIEW |",
+ );
+ });
+
+ it("re-inserts a separator when dropping the inner space would glue the bold to the next word", () => {
+ expect(formatMarkdownText("two flavors: **real business use cases **(recruitment, grants)")).toBe(
+ "two flavors: **real business use cases** (recruitment, grants)",
+ );
+ });
+
it("separates a heading glued to preceding text", () => {
expect(formatMarkdownText("Schluss## Titel")).toBe("Schluss\n\n## Titel");
});
diff --git a/src/components/groups/group-utils.ts b/src/components/groups/group-utils.ts
index 742955fc..0fc03840 100644
--- a/src/components/groups/group-utils.ts
+++ b/src/components/groups/group-utils.ts
@@ -209,12 +209,37 @@ export function formatMarkdownText(text: string): string {
// 5. NORMALIZE WHITESPACE INSIDE DOUBLE ASTERISKS (**word ** -> **word**, ** word** -> **word**)
// CommonMark explicitly disallows leading whitespace after opening ** or trailing whitespace before closing **.
// Use [\s\u00a0] to also match non-breaking spaces and other Unicode whitespace.
+ // The inner whitespace is dropped, but a separator is re-inserted OUTSIDE the
+ // delimiter whenever dropping it would glue the bold to an adjacent word:
+ // "**cases **(recruitment" must become "**cases** (recruitment", while
+ // "mit ** Fett ** hier" keeps its single spaces rather than gaining doubles.
+ const needsSepAfter = (ch: string | undefined) => !!ch && !/[\s\u00a0.,;:!?)\]}|]/.test(ch);
+ const needsSepBefore = (ch: string | undefined) => !!ch && !/[\s\u00a0([{|]/.test(ch);
// Pass A: both sides have whitespace (** word **)
- formatted = formatted.replace(/\*\*[\s\u00a0]+([^*]+?)[\s\u00a0]+\*\*/g, (_m, inner: string) => `**${inner.trim()}**`);
+ formatted = formatted.replace(
+ /\*\*[\s\u00a0]+([^*]+?)[\s\u00a0]+\*\*/g,
+ (m, inner: string, offset: number, str: string) => {
+ const before = needsSepBefore(str[offset - 1]) ? " " : "";
+ const after = needsSepAfter(str[offset + m.length]) ? " " : "";
+ return `${before}**${inner.trim()}**${after}`;
+ },
+ );
// Pass B: leading whitespace only (** word**)
- formatted = formatted.replace(/\*\*[\s\u00a0]+([^*]+?)\*\*/g, (_m, inner: string) => `**${inner.trim()}**`);
+ formatted = formatted.replace(
+ /\*\*[\s\u00a0]+([^*]+?)\*\*/g,
+ (_m, inner: string, offset: number, str: string) => {
+ const before = needsSepBefore(str[offset - 1]) ? " " : "";
+ return `${before}**${inner.trim()}**`;
+ },
+ );
// Pass C: trailing whitespace only (**word **)
- formatted = formatted.replace(/\*\*([^*]+?)[\s\u00a0]+\*\*/g, (_m, inner: string) => `**${inner.trim()}**`);
+ formatted = formatted.replace(
+ /\*\*([^*]+?)[\s\u00a0]+\*\*/g,
+ (m, inner: string, offset: number, str: string) => {
+ const after = needsSepAfter(str[offset + m.length]) ? " " : "";
+ return `**${inner.trim()}**${after}`;
+ },
+ );
// 6. Fix missing space BEFORE opening ** when glued to preceding word (e.g. "word**bold**" -> "word **bold**")
formatted = formatted.replace(/([a-zA-Z0-9äöüßÄÖÜ,.:;!?])\*\*([^\s*])/g, "$1 **$2");
diff --git a/src/components/operator/__tests__/operator-activation.test.tsx b/src/components/operator/__tests__/operator-activation.test.tsx
index 809eba45..41b27ec2 100644
--- a/src/components/operator/__tests__/operator-activation.test.tsx
+++ b/src/components/operator/__tests__/operator-activation.test.tsx
@@ -244,9 +244,14 @@ describe("OperatorActivation", () => {
expect(onActivate.mock.calls[0]![0]).toMatchObject({ scope: "read_write" });
});
- it("explains the write canary while read & write is selected", async () => {
+ it("explains the gate verification and background test write while read & write is selected", async () => {
await toReviewStep();
- expect(await screen.findByTestId("operator-scope-write-warning")).toHaveTextContent(/write canary/i);
+ const warning = await screen.findByTestId("operator-scope-write-warning");
+ // Honest about the new flow: the gate is verified before activation
+ // finishes, the empirical test write runs in the background afterwards.
+ expect(warning).toHaveTextContent(/verifies the approval gate/i);
+ expect(warning).toHaveTextContent(/background/i);
+ expect(warning).toHaveTextContent(/removed immediately/i);
});
it("can be opted down to read-only, and submits that choice", async () => {
diff --git a/src/components/operator/operator-activation.tsx b/src/components/operator/operator-activation.tsx
index 09744a69..9fa3dac2 100644
--- a/src/components/operator/operator-activation.tsx
+++ b/src/components/operator/operator-activation.tsx
@@ -65,11 +65,11 @@ export function OperatorActivation({
const oidcEnabled = method === "keycloak";
const busy = stage !== "idle" && stage !== "done";
- // The canary stages are the LONG wait (each drives a real LLM conversation;
- // they run in parallel but still dominate activation time). A static phrase
- // there reads as frozen — rotate through wait phrases every few seconds so
- // the indicator keeps visibly moving.
- const isLongStage = stage === "canary" || stage === "write-canary";
+ // Provisioning is the LONG wait now that the LLM connection checks run in
+ // the background after activation (setup-api builds and deploys the whole
+ // agent). A static phrase there reads as frozen — rotate through wait
+ // phrases every few seconds so the indicator keeps visibly moving.
+ const isLongStage = stage === "provisioning";
const [waitTick, setWaitTick] = useState(0);
useEffect(() => {
setWaitTick(0);
@@ -81,9 +81,9 @@ export function OperatorActivation({
const base = t(`operator.stage.${stage}`);
if (!isLongStage || waitTick === 0) return base;
const extras = [
- t("operator.stage.longWait1", "Both connection checks run in parallel — this is the longest step…"),
- t("operator.stage.longWait2", "A real conversation is being run against the new operator — the model is thinking…"),
- t("operator.stage.longWait3", "Still working — this can take up to a minute on slower providers…"),
+ t("operator.stage.longWait1", "The operator agent is being built and deployed — this is the longest step…"),
+ t("operator.stage.longWait2", "Wiring up tools and the approval gate on the new agent…"),
+ t("operator.stage.longWait3", "Still working — deployment can take a while on a busy platform…"),
];
return extras[(waitTick - 1) % extras.length]!;
}, [stage, isLongStage, waitTick, t]);
@@ -419,9 +419,10 @@ interface ScopeFieldProps {
* Selects between read & write (the default) and read-only.
*
* Write-first, freely selectable on first activation: the protections that
- * actually matter are not this radio but the per-write approval gate and the
- * write canary activation runs before leaving a write-capable operator
- * deployed. Read-only is the deliberate opt-down for an admin who wants a
+ * actually matter are not this radio but the per-write approval gate,
+ * verified deterministically before activation finishes, plus the background
+ * write probe that removes the operator if a real write ever executes without
+ * pausing. Read-only is the deliberate opt-down for an admin who wants a
* purely inspecting operator — not a hurdle to be cleared first.
*/
function ScopeField({ scope, writeScopeAvailable, onChange }: ScopeFieldProps) {
@@ -499,7 +500,7 @@ function ScopeField({ scope, writeScopeAvailable, onChange }: ScopeFieldProps) {
{t(
"operator.activation.scope.writeWarning",
- "Activating will run a write canary: a real, harmless test write that must pause for approval before this deployment finishes. If it does not pause, activation is refused and the operator is removed rather than left deployed with an unverified write gate.",
+ "Activation verifies the approval gate against the stored policy before it finishes. A real, harmless test write then runs in the background — if it ever executes without pausing for approval, the operator is removed immediately.",
)}
)}
diff --git a/src/components/operator/operator-chat.tsx b/src/components/operator/operator-chat.tsx
index 77dc1024..e336fb50 100644
--- a/src/components/operator/operator-chat.tsx
+++ b/src/components/operator/operator-chat.tsx
@@ -28,6 +28,8 @@ import { cn } from "@/lib/utils";
export interface OperatorChatProps {
messages: ChatMessage[];
events: PipelineEvent[];
+ /** Live tool_call names for the turn in flight — drives "Using {tool}…". */
+ liveToolCalls?: string[];
/** Completed turns' traces, keyed by the agent message they belong to. */
tracesByMessageId: Record;
isStreaming: boolean;
@@ -105,6 +107,7 @@ export interface OperatorChatProps {
export function OperatorChat({
messages,
events,
+ liveToolCalls,
tracesByMessageId,
isStreaming,
error,
@@ -279,6 +282,7 @@ export function OperatorChat({
events={message.isStreaming ? events : tracesByMessageId[message.id]!}
isLive={Boolean(message.isStreaming) && isStreaming}
showInternalSteps={false}
+ liveToolCalls={message.isStreaming ? liveToolCalls : undefined}
/>
) : null}
diff --git a/src/components/operator/operator-drawer.tsx b/src/components/operator/operator-drawer.tsx
index c31f5f1f..d55d874c 100644
--- a/src/components/operator/operator-drawer.tsx
+++ b/src/components/operator/operator-drawer.tsx
@@ -221,6 +221,7 @@ export function OperatorDrawer() {
{
});
});
-describe("useActivateOperator — parallel canaries", () => {
+describe("useActivateOperator — deterministic checks only; LLM probes moved to background", () => {
beforeEach(() => {
server.resetHandlers();
vi.spyOn(console, "error").mockImplementation(() => {});
});
- it("aborts the read canary's stream when the write canary rolls the activation back", async () => {
- // Promise.all rejects without cancelling siblings — without the explicit
- // abort, the read canary's SSE fetch would sit pending against a deleted
- // agent until its own timeout, long after activation error handling ended.
+ it("finishes WITHOUT starting any probe conversation, and reports the dry-run verdict", async () => {
+ // The read canary and the live write probe each drive a real model
+ // conversation — they were the bulk of the activation wait. Activation now
+ // ends at the deterministic checks; if any /agents/:id/start fires before
+ // success, a probe leaked back into the blocking path.
const spy = { undeployed: false, deleted: false };
serveProvisioning(GOOD_GATE, spy);
- let readStreamAborted = false;
+ let probeConversationStarted = false;
server.use(
- // The deterministic check fails closed → write canary rolls back and
- // rejects without ever probing.
- http.post("*/administration/operator/gate-dry-run", () => new HttpResponse(null, { status: 500 })),
- // The read canary's conversation: starts fine…
- http.post("*/agents/:agentId/start", () =>
- HttpResponse.json(null, {
- status: 201,
- headers: { Location: "eddi://ai.labs.conversation/conversationstore/conversations/conv-read" },
- }),
+ http.post("*/administration/operator/gate-dry-run", () =>
+ HttpResponse.json({ policyPresent: true, gated: true, matchedPattern: "http.patch:*" }),
),
- // …and its stream is held open until the client aborts it.
- http.post("*/agents/:conversationId/stream", async ({ request }) => {
- await new Promise((resolve) => {
- if (request.signal.aborted) {
- readStreamAborted = true;
- resolve();
- return;
- }
- request.signal.addEventListener("abort", () => {
- readStreamAborted = true;
- resolve();
- });
- });
- return new HttpResponse(null, { status: 200 });
+ http.post("*/agents/:agentId/start", () => {
+ probeConversationStarted = true;
+ return HttpResponse.json(null, { status: 201, headers: { Location: "/agents/conv-x" } });
}),
);
@@ -225,9 +208,100 @@ describe("useActivateOperator — parallel canaries", () => {
apiKey: "sk-test",
});
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(probeConversationStarted).toBe(false);
+ expect(result.current.data?.policyVerified).toBe(true);
+ // The spec is carried in the outcome so runPostActivationProbes can reuse
+ // it instead of fetching a copy that may have drifted.
+ expect(result.current.data?.spec).toBeTruthy();
+ expect(spy.deleted).toBe(false);
+ });
+
+ it("still fails closed — rolls back — when the dry-run itself errors (not 404)", async () => {
+ const spy = { undeployed: false, deleted: false };
+ serveProvisioning(GOOD_GATE, spy);
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () => new HttpResponse(null, { status: 500 })),
+ );
+
+ const { result } = renderHook(() => useActivateOperator(), { wrapper });
+ result.current.mutate({
+ agentName: "EDDI Platform Operator",
+ config: config({ scope: "read_write" }),
+ apiKey: "sk-test",
+ });
+
await waitFor(() => expect(result.current.isError).toBe(true));
- await waitFor(() => expect(readStreamAborted).toBe(true));
- // The rollback itself still happened.
+ expect(result.current.error?.message).toMatch(/could not verify the approval gate/i);
await waitFor(() => expect(spy.undeployed && spy.deleted).toBe(true));
});
+
+ it("proceeds UNVERIFIED — policyVerified false, nothing deleted — on an old backend (dry-run 404)", async () => {
+ const spy = { undeployed: false, deleted: false };
+ serveProvisioning(GOOD_GATE, spy);
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () => new HttpResponse(null, { status: 404 })),
+ );
+
+ const { result } = renderHook(() => useActivateOperator(), { wrapper });
+ result.current.mutate({
+ agentName: "EDDI Platform Operator",
+ config: config({ scope: "read_write" }),
+ apiKey: "sk-test",
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.policyVerified).toBe(false);
+ expect(spy.deleted).toBe(false);
+ });
+
+ it("read_only reports policyVerified null — there is nothing to verify", async () => {
+ const spy = { undeployed: false, deleted: false };
+ serveProvisioning(GOOD_GATE, spy);
+
+ const { result } = renderHook(() => useActivateOperator(), { wrapper });
+ result.current.mutate({
+ agentName: "EDDI Platform Operator",
+ config: config({ scope: "read_only" }),
+ apiKey: "sk-test",
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.policyVerified).toBeNull();
+ });
+});
+
+describe("runPostActivationProbes", () => {
+ beforeEach(() => {
+ server.resetHandlers();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ it("reports the read canary through the callback and never runs a write probe for read_only", async () => {
+ // The read canary fails fast here (start 500) — what matters is the
+ // callback wiring, not the canary's own logic (tested in its own file).
+ server.use(
+ http.post("*/agents/:agentId/start", () => new HttpResponse(null, { status: 500 })),
+ http.post("*/administration/operator/canary-result", () => new HttpResponse(null, { status: 204 })),
+ );
+
+ const readResults: unknown[] = [];
+ const writeReports: unknown[] = [];
+ await runPostActivationProbes(
+ {
+ config: { ...config({ scope: "read_only" }), agentId: "op-1", version: 1, enabled: true },
+ gate: { verified: true, checkedVersions: [1] },
+ policyVerified: null,
+ spec: { raw: { openapi: "3.1.0", paths: {} }, paths: {} },
+ },
+ {
+ onReadResult: (r) => readResults.push(r),
+ onWriteResult: (r) => writeReports.push(r),
+ },
+ );
+
+ expect(readResults).toHaveLength(1);
+ expect((readResults[0] as { ok: boolean }).ok).toBe(false);
+ expect(writeReports).toHaveLength(0);
+ });
});
diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts
index 6802eab9..31db311b 100644
--- a/src/hooks/use-chat.ts
+++ b/src/hooks/use-chat.ts
@@ -715,6 +715,20 @@ function handleSSEEvent(event: SSEEvent, store: typeof useChatStore): boolean {
});
return false;
}
+ case "tool_call": {
+ // Live "Using {tool}…" signal — the backend emits the NAME right before
+ // each tool executes (arguments arrive later, redacted, in the
+ // task_complete toolTrace). Feeds the status line only, not the turns.
+ try {
+ const parsed = JSON.parse(event.data);
+ if (typeof parsed.tool === "string" && parsed.tool) {
+ debug.addToolCall(parsed.tool);
+ }
+ } catch {
+ // Malformed payload — the status line just keeps saying "Thinking…".
+ }
+ return false;
+ }
case "task_start": {
store.getState().setThinking(true);
// Parse event data for structured pipeline info
diff --git a/src/hooks/use-debug-events.ts b/src/hooks/use-debug-events.ts
index 6d5168e3..57b3194a 100644
--- a/src/hooks/use-debug-events.ts
+++ b/src/hooks/use-debug-events.ts
@@ -103,6 +103,14 @@ interface DebugState {
turns: PipelineTurn[];
currentTurnEvents: PipelineEvent[];
currentTurnStart: number;
+ /**
+ * Tool names from live `tool_call` SSE events, in call order, current turn
+ * only. Kept OUT of currentTurnEvents: the authoritative per-task record
+ * (with arguments and results) still arrives in task_complete's toolTrace,
+ * and storing both in one list would double-count. This list exists solely
+ * so the status line can say "Using {tool}…" while the turn is running.
+ */
+ liveToolCalls: string[];
// UI state
isDebugOpen: boolean;
@@ -112,6 +120,7 @@ interface DebugState {
// Actions
addEvent: (event: PipelineEvent) => void;
+ addToolCall: (tool: string) => void;
finalizeTurn: () => void;
setDebugOpen: (open: boolean) => void;
toggleDebug: () => void;
@@ -161,6 +170,7 @@ export const useDebugStore = create((set) => ({
turns: [],
currentTurnEvents: [],
currentTurnStart: 0,
+ liveToolCalls: [],
isDebugOpen: loadDebugPref(),
activeTab: "pipeline",
selectedTurnIndex: null,
@@ -172,9 +182,16 @@ export const useDebugStore = create((set) => ({
currentTurnStart: s.currentTurnStart || event.timestamp,
})),
+ addToolCall: (tool) =>
+ set((s) => ({ liveToolCalls: [...s.liveToolCalls, tool] })),
+
finalizeTurn: () =>
set((s) => {
- if (s.currentTurnEvents.length === 0) return s;
+ if (s.currentTurnEvents.length === 0) {
+ // No pipeline events, but a stale live-tool list must still not leak
+ // into the next turn's status line.
+ return s.liveToolCalls.length ? { ...s, liveToolCalls: [] } : s;
+ }
const events = s.currentTurnEvents;
const totalDurationMs = events.reduce(
@@ -193,6 +210,7 @@ export const useDebugStore = create((set) => ({
turns: [...s.turns, newTurn],
currentTurnEvents: [],
currentTurnStart: 0,
+ liveToolCalls: [],
selectedTurnIndex: null,
};
}),
@@ -225,6 +243,7 @@ export const useDebugStore = create((set) => ({
turns: [],
currentTurnEvents: [],
currentTurnStart: 0,
+ liveToolCalls: [],
selectedTurnIndex: null,
}),
}));
diff --git a/src/hooks/use-operator-chat.ts b/src/hooks/use-operator-chat.ts
index 5043e8bf..7999b5c7 100644
--- a/src/hooks/use-operator-chat.ts
+++ b/src/hooks/use-operator-chat.ts
@@ -33,6 +33,13 @@ export interface OperatorChatState {
messages: ChatMessage[];
/** Pipeline events for the turn in flight, fed straight to `ChatActivity`. */
events: PipelineEvent[];
+ /**
+ * Tool names from live `tool_call` SSE events for the turn in flight, in
+ * call order — drives "Using {tool}…" in the status line. Kept separate from
+ * `events`: the authoritative record (arguments, results) still arrives in
+ * task_complete's toolTrace, and merging both would double-count.
+ */
+ liveToolCalls: string[];
/**
* Completed turns' traces, keyed by the agent message they belong to.
*
@@ -273,6 +280,7 @@ async function pollUntilSettled(
export const useOperatorChatStore = create((set, get) => ({
messages: [],
events: [],
+ liveToolCalls: [],
tracesByMessageId: {},
isStreaming: false,
error: null,
@@ -306,6 +314,7 @@ export const useOperatorChatStore = create((set, get) => ({
set({
messages: [],
events: [],
+ liveToolCalls: [],
tracesByMessageId: {},
isStreaming: false,
error: null,
@@ -421,6 +430,7 @@ export const useOperatorChatStore = create((set, get) => ({
...s,
messages: [...s.messages, userMessage, agentPlaceholder],
events: [],
+ liveToolCalls: [],
isStreaming: true,
error: null,
}));
@@ -568,6 +578,21 @@ export const useOperatorChatStore = create((set, get) => ({
break;
}
+ if (event.type === "tool_call") {
+ // Live "Using {tool}…" signal — name only; arguments arrive later,
+ // redacted, in the task_complete toolTrace.
+ try {
+ const parsed: { tool?: unknown } = JSON.parse(event.data);
+ if (typeof parsed.tool === "string" && parsed.tool) {
+ const tool = parsed.tool;
+ set((s) => ({ ...s, liveToolCalls: [...s.liveToolCalls, tool] }));
+ }
+ } catch {
+ // Malformed payload — the status line just keeps its last state.
+ }
+ continue;
+ }
+
const pipelineEvent = toPipelineEvent(event);
if (pipelineEvent) {
set((s) => ({ ...s, events: [...s.events, pipelineEvent] }));
@@ -773,6 +798,7 @@ export function useOperatorChat(config: OperatorConfig | null | undefined) {
// abortController swap, a token streamed into someone else's turn).
const messages = useOperatorChatStore((s) => s.messages);
const events = useOperatorChatStore((s) => s.events);
+ const liveToolCalls = useOperatorChatStore((s) => s.liveToolCalls);
const tracesByMessageId = useOperatorChatStore((s) => s.tracesByMessageId);
const isStreaming = useOperatorChatStore((s) => s.isStreaming);
const error = useOperatorChatStore((s) => s.error);
@@ -805,6 +831,7 @@ export function useOperatorChat(config: OperatorConfig | null | undefined) {
return {
messages,
events,
+ liveToolCalls,
tracesByMessageId,
isStreaming,
error,
diff --git a/src/hooks/use-operator.ts b/src/hooks/use-operator.ts
index b58eaf68..18d86973 100644
--- a/src/hooks/use-operator.ts
+++ b/src/hooks/use-operator.ts
@@ -18,10 +18,15 @@ import {
reportOperatorGateStatus,
type GateVerificationResult,
type OperatorConfig,
+ type FetchedSpec,
} from "@/lib/api/operator";
import { undeployAgent, deleteAgent } from "@/lib/api/agents";
import { endpointsForScope } from "@/lib/operator/tool-scopes";
-import { enforceWriteCanaryGate, type WriteCanaryResult } from "@/lib/operator/write-canary";
+import {
+ enforceGateDryRun,
+ runBackgroundWriteProbe,
+ type WriteProbeReport,
+} from "@/lib/operator/write-canary";
/* ─── Query Keys ─── */
@@ -86,17 +91,26 @@ export type ActivationStage =
| "resolving-version"
| "saving"
| "verifying-gate"
- | "canary"
- | "write-canary"
| "done";
-/** What activation returns: the saved config plus the probe outcomes. */
+/**
+ * What activation returns: the saved config plus the DETERMINISTIC check
+ * outcomes. The LLM probes (read canary, live write probe) no longer block
+ * activation — run them afterwards via {@link runPostActivationProbes}, which
+ * needs the `spec` carried here.
+ */
export interface ActivationOutcome {
config: OperatorConfig;
- canary: CanaryResult;
gate: GateVerificationResult;
- /** Only run for scope "read_write" — null for a read_only activation. */
- writeCanary: WriteCanaryResult | null;
+ /**
+ * Whether gate-dry-run deterministically verified the stored policy gates
+ * the probe's target write. `null` for read_only (nothing to verify);
+ * `false` means the backend predates gate-dry-run — NOT that the gate is
+ * broken (a proven-broken policy throws and rolls back instead).
+ */
+ policyVerified: boolean | null;
+ /** The spec activation provisioned against, for the background probes. */
+ spec: FetchedSpec;
}
export interface ActivateParams {
@@ -149,7 +163,7 @@ export function useActivateOperator() {
// "off", because the config variable it reads was never written. It was
// invisible, unmanaged, and a retry made a second one:
// removeSupersededAgent only ever cleans up the agent recorded in the
- // config. The write-canary path below already rolls back for exactly this
+ // config. The gate checks below already roll back for exactly this
// reason; these three steps simply never got the same treatment.
let version: number;
let next: OperatorConfig;
@@ -203,14 +217,14 @@ export function useActivateOperator() {
// proceeding would leave exactly the hole the old two-step bootstrap
// existed to close.
//
- // The write canary below is NOT a substitute for this check. It provokes
- // one endpoint (`PATCH /descriptorstore/descriptors/{id}`), so it proves
- // the patch pattern pauses and says nothing about `http.post:*`,
- // `http.put:*` or `http.delete:*`. A document whose gate covers only some
- // write methods passes the canary while leaving the rest ungated;
- // `gateLooksInstalled` is what inspects the whole pattern set. The two are
- // complementary: this one checks the configuration is sound, the canary
- // checks it is actually enforced at runtime.
+ // The gate dry-run below is NOT a substitute for this check. It
+ // classifies one endpoint (`PATCH /descriptorstore/descriptors/{id}`),
+ // so it proves the patch pattern is gated and says nothing about
+ // `http.post:*`, `http.put:*` or `http.delete:*`. A document whose gate
+ // covers only some write methods passes the dry-run while leaving the
+ // rest ungated; `gateLooksInstalled` is what inspects the whole pattern
+ // set. The two are complementary: this one checks the pattern set is
+ // complete, the dry-run checks the classifier actually gates the target.
onStage?.("verifying-gate");
const gate = await verifyGateInstalled(result.agentId);
await reportOperatorGateStatus(gate.verified);
@@ -221,54 +235,33 @@ export function useActivateOperator() {
);
}
- // A READY badge only proves the config loaded. Run one real read so a
- // deployed-but-unreachable operator is reported as such, not as success.
- onStage?.("canary");
-
- // The write canary is the empirical proof, not just configuration: does
- // a real gated write actually pause? A `fail` — or an `unknown` the
- // deterministic dry-run could not vouch for — rolls the whole activation
- // back (undeploy, delete, clear the config variable) rather than merely
- // reporting the failure; an `unknown` whose gate WAS verified
- // deterministically proceeds with a warning. See enforceWriteCanaryGate's
- // own doc comment for the full taxonomy.
+ // The deterministic half of write verification (backend gate-dry-run):
+ // classifies the probe's target write against the STORED policy — pure
+ // function of policy + call address, cannot flake, writes nothing. A
+ // proven-ungated policy (or a failed verification) rolls back fail-closed
+ // inside. This is the ONLY write check activation still waits on.
//
- // The scope check stays here (enforceWriteCanaryGate also no-ops for
- // read_only on its own) so the "write-canary" stage is never announced
- // for an activation that has no write tool to probe.
- // `next`, NOT `config`: the probe has to run against the agent that was
+ // The LLM probes — read canary and live write probe — deliberately do
+ // NOT run here anymore. Each drives a real model conversation and was
+ // the bulk of the activation wait (a minute of "connection check" after
+ // the operator was already deployed and usable), and an inconclusive
+ // outcome proved nothing anyway. They run in the background via
+ // runPostActivationProbes once the admin is already in the chat; the
+ // write probe still tears the operator down on a PROVEN gate breach.
+ //
+ // `next`, NOT `config`: the check has to run against the agent that was
// just provisioned. `config` still carries the PREVIOUS agentId, which on
- // a reconfigure `removeSupersededAgent` deleted a few lines above — so
- // probing it returned "unknown", rolled back an already-deleted agent, and
- // left the new write-capable operator deployed with its config pointer
- // cleared: the exact outcome this rollback exists to prevent. The read
- // canary uses `next` too; these must agree.
- if (next.scope === "read_write") onStage?.("write-canary");
- // In PARALLEL: each canary drives a real conversation (an LLM turn
- // apiece) in its own conversation, and neither depends on the other's
- // outcome — running them back to back was the bulk of the activation
- // wait after provisioning. When the write canary rejects (rollback), the
- // read canary's stream is ABORTED rather than left dangling against a
- // deleted agent: Promise.all rejects without cancelling its siblings, so
- // without the explicit abort the read SSE fetch could sit pending until
- // its own timeout long after activation error handling finished.
- const readCanaryAbort = new AbortController();
- const [canary, writeCanary] = await Promise.all([
- runOperatorCanary(next, readCanaryAbort.signal),
- enforceWriteCanaryGate(next, spec).catch((error: unknown) => {
- readCanaryAbort.abort();
- throw error;
- }),
- ]);
+ // a reconfigure `removeSupersededAgent` deleted a few lines above.
+ const policyVerified = await enforceGateDryRun(next, spec);
onStage?.("done");
- return { config: next, canary, gate, writeCanary };
+ return { config: next, gate, policyVerified, spec };
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: operatorKeys.all });
},
// A failed activation mutates server state as surely as a successful one:
- // the provisioning rollback above and `enforceWriteCanaryGate`'s
+ // the provisioning rollback above and `enforceGateDryRun`'s
// `resetOperator` both DELETE agents and clear the config variable before
// throwing. Without this the cache still holds the pre-activation config,
// so cancelling out of the form lands on a page reporting an active
@@ -282,6 +275,52 @@ export function useActivateOperator() {
});
}
+/** Callbacks through which the background probes report — see runPostActivationProbes. */
+export interface PostActivationProbeCallbacks {
+ /** The read canary's outcome (one real read through a real conversation). */
+ onReadResult: (result: CanaryResult) => void;
+ /**
+ * The live write probe's outcome (read_write only). `report.tornDown` means
+ * the probe PROVED the gate broken and the operator was already removed —
+ * the UI must re-read the config, not just show a warning.
+ */
+ onWriteResult?: (report: WriteProbeReport) => void;
+}
+
+/**
+ * The LLM probes that used to block activation, now run AFTER it: one real
+ * read (the canary) and — for read_write — one real gated write that must
+ * pause. Fire-and-forget from the activation success handler; each result is
+ * delivered through the callbacks as it arrives, in parallel.
+ *
+ * Deliberately NOT a mutation hook: nothing here belongs to a component's
+ * lifecycle. The probes must keep running (and the write probe must keep its
+ * power to tear down a provably-ungated operator) even if the admin navigates
+ * away from the operator page mid-probe.
+ */
+export async function runPostActivationProbes(
+ outcome: ActivationOutcome,
+ callbacks: PostActivationProbeCallbacks,
+): Promise {
+ const { config, spec, policyVerified } = outcome;
+ await Promise.all([
+ runOperatorCanary(config)
+ .catch(
+ (error: unknown): CanaryResult => ({
+ ok: false,
+ toolCalls: 0,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ )
+ .then((result) => callbacks.onReadResult(result)),
+ config.scope === "read_write"
+ ? runBackgroundWriteProbe(config, spec, policyVerified === true).then((report) => {
+ if (report) callbacks.onWriteResult?.(report);
+ })
+ : Promise.resolve(),
+ ]);
+}
+
/**
* Tear down an operator that is already DEPLOYED but failed a safety check, and
* throw with the reason.
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index caa8cee6..a398bdc6 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -727,7 +727,13 @@
"toolResult": "النتيجة",
"error": "حدث خطأ",
"toolCallsCount_one": "استدعاء أداة واحد",
- "stepsCount": "{{count}} خطوة", "stepsCount_zero": "لا خطوات", "stepsCount_one": "خطوة واحدة", "stepsCount_two": "خطوتان", "stepsCount_few": "{{count}} خطوات", "stepsCount_many": "{{count}} خطوة", "stepsCount_other": "{{count}} خطوة",
+ "stepsCount": "{{count}} خطوة",
+ "stepsCount_zero": "لا خطوات",
+ "stepsCount_one": "خطوة واحدة",
+ "stepsCount_two": "خطوتان",
+ "stepsCount_few": "{{count}} خطوات",
+ "stepsCount_many": "{{count}} خطوة",
+ "stepsCount_other": "{{count}} خطوة",
"toolCallsCount_zero": "لا استدعاءات أدوات",
"toolCallsCount_two": "استدعاءا أداة",
"toolCallsCount_few": "{{count}} استدعاءات أدوات",
@@ -4023,7 +4029,7 @@
"label": "قراءة وكتابة",
"description": "يتيح له أيضًا إنشاء وتعديل الوكلاء ومجموعات الوكلاء والنشر وإلغاء النشر وتعطيل جدول زمني خارج عن السيطرة وتعديل وصف أحد الوكلاء — كل ذلك بانتظار موافقتك أولًا."
},
- "writeWarning": "سيؤدي التفعيل إلى تشغيل اختبار كتابة تجريبي: عملية كتابة اختبارية حقيقية وغير ضارة يجب أن تنتظر الموافقة قبل انتهاء هذا النشر. إذا لم تتوقف مؤقتًا، يُرفض التفعيل ويُزال المُشغّل بدلًا من إبقائه منشورًا ببوابة كتابة غير موثوقة."
+ "writeWarning": "يتحقق التفعيل من بوابة الموافقة مقابل السياسة المخزنة قبل اكتماله. ثم تُنفَّذ عملية كتابة اختبارية حقيقية وغير ضارة في الخلفية — إذا نُفِّذت يومًا دون التوقف للموافقة، يُزال المشغّل فورًا."
},
"safetyPreamble": "قواعد الأمان (غير قابلة للتعديل)",
"safetyPreambleHint": "تُضاف دائمًا في البداية، وتوجّه المُشغّل إلى التعامل مع كل ما تُرجعه أدواته كبيانات غير موثوقة.",
@@ -4053,11 +4059,9 @@
"saving": "يجري حفظ الإعدادات…",
"verifying-gate": "جارٍ التحقق من بوابة الموافقة…",
"done": "تم",
- "canary": "يجري التحقق من أن المُشغّل يصل إلى منصتك…",
- "write-canary": "جارٍ تنفيذ عملية كتابة اختبارية حقيقية، يجب أن تنتظر موافقتك…",
- "longWait1": "يعمل فحصا الاتصال بالتوازي — هذه أطول خطوة…",
- "longWait2": "تجري محادثة حقيقية مع المشغّل الجديد — النموذج يفكر…",
- "longWait3": "لا يزال العمل جاريًا — قد يستغرق ذلك دقيقة مع مزودين أبطأ…"
+ "longWait1": "يجري إنشاء وكيل المشغّل ونشره — هذه أطول خطوة…",
+ "longWait2": "يجري إعداد الأدوات وبوابة الموافقة على الوكيل الجديد…",
+ "longWait3": "العمل مستمر — قد يستغرق النشر بعض الوقت على منصة مشغولة…"
},
"status": {
"title": "المُشغّل",
@@ -4142,11 +4146,14 @@
},
"toast": {
"activated": "تم تفعيل مُشغّل المنصة",
- "activatedReadWrite": "تم تفعيل مُشغّل المنصة — تم التحقق من صلاحية الكتابة",
- "activatedReadWriteUnverified": "تم تفعيل مشغّل المنصة — تم التحقق من بوابة الموافقة؛ كان مسبار الكتابة المباشر غير حاسم",
"deactivated": "تم إلغاء تفعيل مُشغّل المنصة",
"reset": "تم حذف مُشغّل المنصة",
- "activatedButUnreachable": "تم نشر المُشغّل، لكنه لم يتمكن من قراءة منصتك"
+ "activatedButUnreachable": "تم نشر المُشغّل، لكنه لم يتمكن من قراءة منصتك",
+ "activatedGateVerified": "تم تفعيل مشغّل المنصة — تم التحقق من بوابة الموافقة. تعمل فحوصات الاتصال في الخلفية.",
+ "activatedChecking": "تم تفعيل مشغّل المنصة. تعمل فحوصات الاتصال في الخلفية.",
+ "writeProbeVerified": "تم التحقق من صلاحية الكتابة — توقفت عملية كتابة حقيقية محمية للموافقة.",
+ "writeProbeFailed": "لم تصمد بوابة الموافقة — تمت إزالة المشغّل.",
+ "writeProbeInconclusive": "كان اختبار الكتابة المباشر غير حاسم."
},
"canary": {
"genericFailure": "لم ينجح فحص الاتصال.",
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index c9134c13..5b99da7e 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -695,7 +695,9 @@
"toolResult": "Ergebnis",
"error": "Fehler aufgetreten",
"toolCallsCount_one": "{{count}} Tool-Aufruf",
- "stepsCount": "{{count}} Schritte", "stepsCount_one": "{{count}} Schritt", "stepsCount_other": "{{count}} Schritte",
+ "stepsCount": "{{count}} Schritte",
+ "stepsCount_one": "{{count}} Schritt",
+ "stepsCount_other": "{{count}} Schritte",
"toolCallsCount_other": "{{count}} Tool-Aufrufe",
"toolCallsCount": "{{count}} Tool-Aufrufe",
"usingTool": "Verwendet {{tool}} …"
@@ -3907,7 +3909,7 @@
"label": "Lese- und Schreibzugriff",
"description": "Kann außerdem Agenten und Agentengruppen erstellen und ändern, bereitstellen, die Bereitstellung aufheben, einen außer Kontrolle geratenen Zeitplan deaktivieren und die Beschreibung eines Agenten bearbeiten — jeweils erst nach Ihrer Genehmigung."
},
- "writeWarning": "Beim Aktivieren wird ein Schreib-Kanarientest ausgeführt: ein echter, harmloser Testschreibvorgang, der vor Abschluss dieses Deployments auf Ihre Genehmigung warten muss. Pausiert er nicht, wird die Aktivierung verweigert und der Operator entfernt, statt mit einem ungeprüften Schreib-Gate bereitgestellt zu bleiben."
+ "writeWarning": "Die Aktivierung prüft das Freigabe-Gate vor dem Abschluss gegen die gespeicherte Richtlinie. Ein echter, harmloser Testschreibvorgang läuft anschließend im Hintergrund — wird er jemals ohne Freigabepause ausgeführt, wird der Operator sofort entfernt."
},
"safetyPreamble": "Sicherheitsregeln (nicht bearbeitbar)",
"safetyPreambleHint": "Wird immer vorangestellt. Sie weist den Operator an, alles von seinen Tools Zurückgelieferte als nicht vertrauenswürdige Daten zu behandeln.",
@@ -3937,11 +3939,9 @@
"saving": "Konfiguration wird gespeichert …",
"verifying-gate": "Die Freigabesperre wird überprüft…",
"done": "Fertig",
- "canary": "Es wird geprüft, ob der Operator Ihre Plattform erreicht …",
- "write-canary": "Ein echter Testschreibvorgang wird ausgeführt, der auf Ihre Genehmigung wartet…",
- "longWait1": "Beide Verbindungsprüfungen laufen parallel — dies ist der längste Schritt…",
- "longWait2": "Gegen den neuen Operator läuft eine echte Konversation — das Modell denkt nach…",
- "longWait3": "Läuft noch — bei langsameren Anbietern kann das bis zu einer Minute dauern…"
+ "longWait1": "Der Operator-Agent wird erstellt und bereitgestellt — dies ist der längste Schritt…",
+ "longWait2": "Werkzeuge und Freigabe-Gate werden am neuen Agenten eingerichtet…",
+ "longWait3": "Läuft noch — die Bereitstellung kann auf einer ausgelasteten Plattform etwas dauern…"
},
"status": {
"title": "Operator",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "Plattform-Operator aktiviert",
- "activatedReadWrite": "Plattform-Operator aktiviert — Schreibzugriff verifiziert",
- "activatedReadWriteUnverified": "Platform Operator aktiviert — Freigabe-Gate verifiziert; die Live-Schreibprobe war ergebnislos",
"deactivated": "Plattform-Operator deaktiviert",
"reset": "Plattform-Operator gelöscht",
- "activatedButUnreachable": "Operator deployt, konnte Ihre Plattform aber nicht lesen"
+ "activatedButUnreachable": "Operator deployt, konnte Ihre Plattform aber nicht lesen",
+ "activatedGateVerified": "Platform Operator aktiviert — Freigabe-Gate verifiziert. Verbindungsprüfungen laufen im Hintergrund.",
+ "activatedChecking": "Platform Operator aktiviert. Verbindungsprüfungen laufen im Hintergrund.",
+ "writeProbeVerified": "Schreibzugriff verifiziert — ein echter, geschützter Schreibvorgang wurde zur Freigabe angehalten.",
+ "writeProbeFailed": "Das Freigabe-Gate hat nicht gehalten — der Operator wurde entfernt.",
+ "writeProbeInconclusive": "Der Live-Schreibtest war ergebnislos."
},
"canary": {
"genericFailure": "Die Verbindungsprüfung war nicht erfolgreich.",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index c51506f1..a6a23c33 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -724,7 +724,9 @@
"toolResult": "Result",
"error": "Error occurred",
"toolCallsCount_one": "{{count}} tool call",
- "stepsCount": "{{count}} steps", "stepsCount_one": "{{count}} step", "stepsCount_other": "{{count}} steps",
+ "stepsCount": "{{count}} steps",
+ "stepsCount_one": "{{count}} step",
+ "stepsCount_other": "{{count}} steps",
"toolCallsCount_other": "{{count}} tool calls",
"toolCallsCount": "{{count}} tool calls",
"usingTool": "Using {{tool}}…"
@@ -3907,7 +3909,7 @@
"label": "Read & write",
"description": "Also lets it create and modify agents and agent groups, deploy, undeploy, disable a runaway schedule, and edit an agent's descriptor — each one paused for your approval first."
},
- "writeWarning": "Activating will run a write canary: a real, harmless test write that must pause for approval before this deployment finishes. If it does not pause, activation is refused and the operator is removed rather than left deployed with an unverified write gate."
+ "writeWarning": "Activation verifies the approval gate against the stored policy before it finishes. A real, harmless test write then runs in the background — if it ever executes without pausing for approval, the operator is removed immediately."
},
"safetyPreamble": "Safety rules (not editable)",
"safetyPreambleHint": "Always prepended. It tells the operator to treat everything its tools return as untrusted data.",
@@ -3937,9 +3939,9 @@
"saving": "Saving configuration…",
"verifying-gate": "Verifying the approval gate…",
"done": "Done",
- "canary": "Checking the operator can reach your platform…",
- "write-canary": "Running a real test write, which must pause for your approval…",
- "longWait1": "Both connection checks run in parallel — this is the longest step…", "longWait2": "A real conversation is being run against the new operator — the model is thinking…", "longWait3": "Still working — this can take up to a minute on slower providers…"
+ "longWait1": "The operator agent is being built and deployed — this is the longest step…",
+ "longWait2": "Wiring up tools and the approval gate on the new agent…",
+ "longWait3": "Still working — deployment can take a while on a busy platform…"
},
"status": {
"title": "Operator",
@@ -4024,11 +4026,14 @@
},
"toast": {
"activated": "Platform Operator activated",
- "activatedReadWrite": "Platform Operator activated — write access verified",
- "activatedReadWriteUnverified": "Platform Operator activated — approval gate verified; the live write probe was inconclusive",
"deactivated": "Platform Operator deactivated",
"reset": "Platform Operator deleted",
- "activatedButUnreachable": "Operator deployed, but it could not read your platform"
+ "activatedButUnreachable": "Operator deployed, but it could not read your platform",
+ "activatedGateVerified": "Platform Operator activated — approval gate verified. Connection checks are running in the background.",
+ "activatedChecking": "Platform Operator activated. Connection checks are running in the background.",
+ "writeProbeVerified": "Write access verified — a real gated write paused for approval.",
+ "writeProbeFailed": "The approval gate did not hold — the operator was removed.",
+ "writeProbeInconclusive": "The live write probe was inconclusive."
},
"canary": {
"genericFailure": "The connection check did not succeed.",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 6b4fb455..6ca9032e 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -703,7 +703,10 @@
"toolResult": "Resultado",
"error": "Se produjo un error",
"toolCallsCount_one": "{{count}} llamada a herramienta",
- "stepsCount": "{{count}} pasos", "stepsCount_one": "{{count}} paso", "stepsCount_many": "{{count}} pasos", "stepsCount_other": "{{count}} pasos",
+ "stepsCount": "{{count}} pasos",
+ "stepsCount_one": "{{count}} paso",
+ "stepsCount_many": "{{count}} pasos",
+ "stepsCount_other": "{{count}} pasos",
"toolCallsCount_many": "{{count}} llamadas a herramientas",
"toolCallsCount_other": "{{count}} llamadas a herramientas",
"toolCallsCount": "{{count}} llamadas a herramientas",
@@ -3936,7 +3939,7 @@
"label": "Lectura y escritura",
"description": "También le permite crear y modificar agentes y grupos de agentes, desplegar, retirar el despliegue, deshabilitar una programación descontrolada y editar el descriptor de un agente — cada una a la espera de tu aprobación."
},
- "writeWarning": "Al activar se ejecutará una prueba canario de escritura: una escritura de prueba real e inofensiva que debe quedar a la espera de aprobación antes de que termine este despliegue. Si no se pausa, la activación se rechaza y el operador se elimina en lugar de dejarlo desplegado con una puerta de escritura sin verificar."
+ "writeWarning": "La activación verifica la puerta de aprobación contra la política almacenada antes de finalizar. Después, una escritura de prueba real e inocua se ejecuta en segundo plano — si alguna vez se ejecuta sin pausarse para aprobación, el operador se elimina de inmediato."
},
"safetyPreamble": "Reglas de seguridad (no editables)",
"safetyPreambleHint": "Se anteponen siempre. Indican al operador que trate todo lo que devuelvan sus herramientas como datos no fiables.",
@@ -3966,11 +3969,9 @@
"saving": "Guardando la configuración…",
"verifying-gate": "Verificando la puerta de aprobación…",
"done": "Listo",
- "canary": "Comprobando que el operador puede acceder a tu plataforma…",
- "write-canary": "Ejecutando una escritura de prueba real, que debe esperar tu aprobación…",
- "longWait1": "Ambas comprobaciones de conexión se ejecutan en paralelo — este es el paso más largo…",
- "longWait2": "Se está ejecutando una conversación real con el nuevo operador — el modelo está pensando…",
- "longWait3": "Sigue en curso — puede tardar hasta un minuto con proveedores lentos…"
+ "longWait1": "El agente operador se está creando y desplegando — este es el paso más largo…",
+ "longWait2": "Configurando las herramientas y la puerta de aprobación en el nuevo agente…",
+ "longWait3": "Aún en curso — el despliegue puede tardar en una plataforma ocupada…"
},
"status": {
"title": "Operador",
@@ -4055,11 +4056,14 @@
},
"toast": {
"activated": "Operador de plataforma activado",
- "activatedReadWrite": "Operador de la plataforma activado — acceso de escritura verificado",
- "activatedReadWriteUnverified": "Operador de plataforma activado — puerta de aprobación verificada; la sonda de escritura en vivo no fue concluyente",
"deactivated": "Operador de plataforma desactivado",
"reset": "Operador de plataforma eliminado",
- "activatedButUnreachable": "Operador desplegado, pero no pudo leer tu plataforma"
+ "activatedButUnreachable": "Operador desplegado, pero no pudo leer tu plataforma",
+ "activatedGateVerified": "Operador de plataforma activado — puerta de aprobación verificada. Las comprobaciones de conexión se ejecutan en segundo plano.",
+ "activatedChecking": "Operador de plataforma activado. Las comprobaciones de conexión se ejecutan en segundo plano.",
+ "writeProbeVerified": "Acceso de escritura verificado — una escritura real protegida se pausó para aprobación.",
+ "writeProbeFailed": "La puerta de aprobación no resistió — el operador fue eliminado.",
+ "writeProbeInconclusive": "La prueba de escritura en vivo no fue concluyente."
},
"canary": {
"genericFailure": "La comprobación de conexión no se completó correctamente.",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index adc15a1f..0f28a81a 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -703,7 +703,10 @@
"toolResult": "Résultat",
"error": "Une erreur s'est produite",
"toolCallsCount_one": "{{count}} appel d'outil",
- "stepsCount": "{{count}} étapes", "stepsCount_one": "{{count}} étape", "stepsCount_many": "{{count}} étapes", "stepsCount_other": "{{count}} étapes",
+ "stepsCount": "{{count}} étapes",
+ "stepsCount_one": "{{count}} étape",
+ "stepsCount_many": "{{count}} étapes",
+ "stepsCount_other": "{{count}} étapes",
"toolCallsCount_many": "{{count}} appels d'outils",
"toolCallsCount_other": "{{count}} appels d'outils",
"toolCallsCount": "{{count}} appels d'outils",
@@ -3936,7 +3939,7 @@
"label": "Lecture et écriture",
"description": "Peut aussi créer et modifier des agents et des groupes d'agents, déployer, annuler le déploiement, désactiver une planification incontrôlée et modifier le descripteur d'un agent — chacune de ces actions étant d'abord soumise à votre approbation."
},
- "writeWarning": "L'activation exécutera un test canari d'écriture : une écriture de test réelle et inoffensive qui doit rester en attente d'approbation avant la fin de ce déploiement. Si elle ne se met pas en pause, l'activation est refusée et l'opérateur est supprimé plutôt que laissé déployé avec une passerelle d'écriture non vérifiée."
+ "writeWarning": "L'activation vérifie la barrière d'approbation par rapport à la politique enregistrée avant de se terminer. Une écriture de test réelle et inoffensive s'exécute ensuite en arrière-plan — si elle s'exécute un jour sans pause d'approbation, l'opérateur est immédiatement supprimé."
},
"safetyPreamble": "Règles de sécurité (non modifiables)",
"safetyPreambleHint": "Toujours ajoutées en tête. Elles indiquent à l'opérateur de traiter tout ce que renvoient ses outils comme des données non fiables.",
@@ -3966,11 +3969,9 @@
"saving": "Enregistrement de la configuration…",
"verifying-gate": "Vérification de la barrière d'approbation…",
"done": "Terminé",
- "canary": "Vérification que l'opérateur atteint votre plateforme…",
- "write-canary": "Exécution d'une écriture de test réelle, qui doit attendre votre approbation…",
- "longWait1": "Les deux vérifications de connexion s'exécutent en parallèle — c'est l'étape la plus longue…",
- "longWait2": "Une vraie conversation est en cours avec le nouvel opérateur — le modèle réfléchit…",
- "longWait3": "Toujours en cours — cela peut prendre jusqu'à une minute selon le fournisseur…"
+ "longWait1": "L'agent opérateur est en cours de création et de déploiement — c'est l'étape la plus longue…",
+ "longWait2": "Configuration des outils et de la barrière d'approbation sur le nouvel agent…",
+ "longWait3": "Toujours en cours — le déploiement peut prendre du temps sur une plateforme chargée…"
},
"status": {
"title": "Opérateur",
@@ -4055,11 +4056,14 @@
},
"toast": {
"activated": "Opérateur de plateforme activé",
- "activatedReadWrite": "Opérateur de plateforme activé — accès en écriture vérifié",
- "activatedReadWriteUnverified": "Opérateur de plateforme activé — barrière d'approbation vérifiée ; la sonde d'écriture en direct n'a pas été concluante",
"deactivated": "Opérateur de plateforme désactivé",
"reset": "Opérateur de plateforme supprimé",
- "activatedButUnreachable": "Opérateur déployé, mais il n'a pas pu lire votre plateforme"
+ "activatedButUnreachable": "Opérateur déployé, mais il n'a pas pu lire votre plateforme",
+ "activatedGateVerified": "Opérateur de plateforme activé — barrière d'approbation vérifiée. Les vérifications de connexion s'exécutent en arrière-plan.",
+ "activatedChecking": "Opérateur de plateforme activé. Les vérifications de connexion s'exécutent en arrière-plan.",
+ "writeProbeVerified": "Accès en écriture vérifié — une écriture réelle protégée a été mise en pause pour approbation.",
+ "writeProbeFailed": "La barrière d'approbation n'a pas tenu — l'opérateur a été supprimé.",
+ "writeProbeInconclusive": "Le test d'écriture en direct n'a pas été concluant."
},
"canary": {
"genericFailure": "La vérification de connexion a échoué.",
diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json
index 8e143f2f..2786dfad 100644
--- a/src/i18n/locales/hi.json
+++ b/src/i18n/locales/hi.json
@@ -695,7 +695,9 @@
"toolResult": "परिणाम",
"error": "त्रुटि हुई",
"toolCallsCount_one": "{{count}} टूल कॉल",
- "stepsCount": "{{count}} चरण", "stepsCount_one": "{{count}} चरण", "stepsCount_other": "{{count}} चरण",
+ "stepsCount": "{{count}} चरण",
+ "stepsCount_one": "{{count}} चरण",
+ "stepsCount_other": "{{count}} चरण",
"toolCallsCount_other": "{{count}} टूल कॉल",
"toolCallsCount": "{{count}} टूल कॉल",
"usingTool": "{{tool}} का उपयोग हो रहा है…"
@@ -3907,7 +3909,7 @@
"label": "पढ़ना और लिखना",
"description": "यह एजेंट और एजेंट समूह बनाने और संशोधित करने, डिप्लॉय करने, अनडिप्लॉय करने, किसी बेकाबू शेड्यूल को अक्षम करने, और किसी एजेंट के विवरण को संपादित करने की भी अनुमति देता है — हर एक पहले आपकी स्वीकृति की प्रतीक्षा करता है।"
},
- "writeWarning": "सक्रिय करने पर एक राइट कैनरी (write canary) चलेगी: एक वास्तविक, हानिरहित परीक्षण लेखन जिसे इस डिप्लॉयमेंट के पूरा होने से पहले स्वीकृति की प्रतीक्षा में रुकना होगा। यदि यह नहीं रुकती, तो सक्रियण अस्वीकार कर दिया जाता है और ऑपरेटर को बिना सत्यापित लेखन गेट के डिप्लॉय छोड़ने के बजाय हटा दिया जाता है।"
+ "writeWarning": "सक्रियण पूरा होने से पहले संग्रहीत नीति के विरुद्ध अनुमोदन गेट की पुष्टि करता है। फिर एक वास्तविक, हानिरहित परीक्षण लेखन पृष्ठभूमि में चलता है — यदि यह कभी अनुमोदन के लिए रुके बिना निष्पादित हो जाए, तो ऑपरेटर तुरंत हटा दिया जाता है।"
},
"safetyPreamble": "सुरक्षा नियम (संपादन योग्य नहीं)",
"safetyPreambleHint": "हमेशा सबसे ऊपर जोड़े जाते हैं। ये ऑपरेटर से कहते हैं कि उसके टूल जो कुछ लौटाएँ उसे अविश्वसनीय डेटा मानें।",
@@ -3937,11 +3939,9 @@
"saving": "कॉन्फ़िगरेशन सहेजा जा रहा है…",
"verifying-gate": "अनुमोदन गेट सत्यापित किया जा रहा है…",
"done": "हो गया",
- "canary": "जाँचा जा रहा है कि ऑपरेटर आपके प्लेटफ़ॉर्म तक पहुँच सकता है…",
- "write-canary": "एक वास्तविक परीक्षण लेखन चलाया जा रहा है, जिसे आपकी स्वीकृति की प्रतीक्षा करनी होगी…",
- "longWait1": "दोनों कनेक्शन जाँचें समानांतर चल रही हैं — यह सबसे लंबा चरण है…",
- "longWait2": "नए ऑपरेटर के साथ एक वास्तविक बातचीत चल रही है — मॉडल सोच रहा है…",
- "longWait3": "अभी भी चल रहा है — धीमे प्रदाताओं पर एक मिनट तक लग सकता है…"
+ "longWait1": "ऑपरेटर एजेंट बनाया और तैनात किया जा रहा है — यह सबसे लंबा चरण है…",
+ "longWait2": "नए एजेंट पर टूल और अनुमोदन गेट सेट किए जा रहे हैं…",
+ "longWait3": "अभी भी जारी है — व्यस्त प्लेटफ़ॉर्म पर तैनाती में समय लग सकता है…"
},
"status": {
"title": "ऑपरेटर",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "प्लेटफ़ॉर्म ऑपरेटर सक्रिय किया गया",
- "activatedReadWrite": "प्लेटफ़ॉर्म ऑपरेटर सक्रिय — लेखन एक्सेस सत्यापित",
- "activatedReadWriteUnverified": "प्लेटफ़ॉर्म ऑपरेटर सक्रिय — अनुमोदन गेट सत्यापित; लाइव राइट प्रोब अनिर्णायक रही",
"deactivated": "प्लेटफ़ॉर्म ऑपरेटर निष्क्रिय किया गया",
"reset": "प्लेटफ़ॉर्म ऑपरेटर हटा दिया गया",
- "activatedButUnreachable": "ऑपरेटर परिनियोजित हुआ, पर आपका प्लेटफ़ॉर्म नहीं पढ़ सका"
+ "activatedButUnreachable": "ऑपरेटर परिनियोजित हुआ, पर आपका प्लेटफ़ॉर्म नहीं पढ़ सका",
+ "activatedGateVerified": "प्लेटफ़ॉर्म ऑपरेटर सक्रिय — अनुमोदन गेट सत्यापित। कनेक्शन जाँचें पृष्ठभूमि में चल रही हैं।",
+ "activatedChecking": "प्लेटफ़ॉर्म ऑपरेटर सक्रिय। कनेक्शन जाँचें पृष्ठभूमि में चल रही हैं।",
+ "writeProbeVerified": "लेखन पहुँच सत्यापित — एक वास्तविक संरक्षित लेखन अनुमोदन के लिए रुका।",
+ "writeProbeFailed": "अनुमोदन गेट टिक नहीं पाया — ऑपरेटर हटा दिया गया।",
+ "writeProbeInconclusive": "लाइव लेखन परीक्षण अनिर्णायक रहा।"
},
"canary": {
"genericFailure": "कनेक्शन जाँच सफल नहीं रही।",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 928e4e1e..83bae9a7 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -695,7 +695,9 @@
"toolResult": "結果",
"error": "エラーが発生しました",
"toolCallsCount_one": "{{count}} 件のツール呼び出し",
- "stepsCount": "{{count}} ステップ", "stepsCount_one": "{{count}} ステップ", "stepsCount_other": "{{count}} ステップ",
+ "stepsCount": "{{count}} ステップ",
+ "stepsCount_one": "{{count}} ステップ",
+ "stepsCount_other": "{{count}} ステップ",
"toolCallsCount_other": "{{count}} 件のツール呼び出し",
"toolCallsCount": "{{count}} 件のツール呼び出し",
"usingTool": "{{tool}} を使用中…"
@@ -3907,7 +3909,7 @@
"label": "読み書き可能",
"description": "エージェントとエージェントグループの作成・変更、デプロイ、デプロイ解除、暴走したスケジュールの無効化、エージェントの説明の編集も行えます — いずれも事前に承認が必要です。"
},
- "writeWarning": "有効化すると書き込みカナリアテストが実行されます。これは実際の、無害なテスト書き込みで、このデプロイが完了する前に承認待ちで一時停止する必要があります。一時停止しない場合、有効化は拒否され、未検証の書き込みゲートのままデプロイされた状態にせず、オペレーターは削除されます。"
+ "writeWarning": "アクティベーションは完了前に、保存されたポリシーに対して承認ゲートを検証します。その後、実際の無害なテスト書き込みがバックグラウンドで実行されます — 承認のための一時停止なしに実行された場合、オペレーターは直ちに削除されます。"
},
"safetyPreamble": "安全上のルール(編集不可)",
"safetyPreambleHint": "常に先頭に付加され、ツールが返すものはすべて信頼できないデータとして扱うようオペレーターに指示します。",
@@ -3937,11 +3939,9 @@
"saving": "設定を保存しています…",
"verifying-gate": "承認ゲートを確認しています…",
"done": "完了",
- "canary": "オペレーターが環境に到達できるか確認しています…",
- "write-canary": "実際のテスト書き込みを実行中です。承認をお待ちください…",
- "longWait1": "2つの接続チェックが並行して実行されています — これが最も時間のかかるステップです…",
- "longWait2": "新しいオペレーターに対して実際の会話を実行中 — モデルが考えています…",
- "longWait3": "処理中です — プロバイダーによっては最大1分かかることがあります…"
+ "longWait1": "オペレーターエージェントを構築してデプロイしています — これが最も時間のかかるステップです…",
+ "longWait2": "新しいエージェントにツールと承認ゲートを設定しています…",
+ "longWait3": "処理中です — 混雑したプラットフォームではデプロイに時間がかかることがあります…"
},
"status": {
"title": "オペレーター",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "プラットフォームオペレーターを有効にしました",
- "activatedReadWrite": "プラットフォームオペレーターを有効化しました — 書き込み権限を確認済みです",
- "activatedReadWriteUnverified": "プラットフォームオペレーターを有効化しました — 承認ゲートは検証済み。ライブ書き込みプローブは結論が出ませんでした",
"deactivated": "プラットフォームオペレーターを無効にしました",
"reset": "プラットフォームオペレーターを削除しました",
- "activatedButUnreachable": "オペレーターをデプロイしましたが、環境を読み取れませんでした"
+ "activatedButUnreachable": "オペレーターをデプロイしましたが、環境を読み取れませんでした",
+ "activatedGateVerified": "プラットフォームオペレーターを有効化しました — 承認ゲートを検証済み。接続チェックはバックグラウンドで実行中です。",
+ "activatedChecking": "プラットフォームオペレーターを有効化しました。接続チェックはバックグラウンドで実行中です。",
+ "writeProbeVerified": "書き込みアクセスを検証しました — 実際のゲート付き書き込みが承認のため一時停止しました。",
+ "writeProbeFailed": "承認ゲートが機能しませんでした — オペレーターは削除されました。",
+ "writeProbeInconclusive": "ライブ書き込みプローブは結論が出ませんでした。"
},
"canary": {
"genericFailure": "接続確認に失敗しました。",
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index 501709e4..49d8fd2e 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -695,7 +695,9 @@
"toolResult": "결과",
"error": "오류가 발생했습니다",
"toolCallsCount_one": "도구 호출 {{count}}건",
- "stepsCount": "{{count}}단계", "stepsCount_one": "{{count}}단계", "stepsCount_other": "{{count}}단계",
+ "stepsCount": "{{count}}단계",
+ "stepsCount_one": "{{count}}단계",
+ "stepsCount_other": "{{count}}단계",
"toolCallsCount_other": "도구 호출 {{count}}건",
"toolCallsCount": "도구 호출 {{count}}건",
"usingTool": "{{tool}} 사용 중…"
@@ -3907,7 +3909,7 @@
"label": "읽기 및 쓰기",
"description": "에이전트와 에이전트 그룹 생성 및 수정, 배포, 배포 취소, 폭주하는 일정 비활성화, 에이전트 설명 편집도 가능합니다 — 각각 먼저 승인을 기다립니다."
},
- "writeWarning": "활성화하면 쓰기 카나리 테스트가 실행됩니다: 실제이지만 무해한 테스트 쓰기 작업으로, 이 배포가 완료되기 전에 승인을 기다리며 일시 중지되어야 합니다. 일시 중지되지 않으면 활성화가 거부되고, 검증되지 않은 쓰기 게이트 상태로 배포된 채 남겨두는 대신 오퍼레이터가 제거됩니다."
+ "writeWarning": "활성화는 완료 전에 저장된 정책에 대해 승인 게이트를 검증합니다. 그 후 실제 무해한 테스트 쓰기가 백그라운드에서 실행됩니다 — 승인을 위해 일시 중지되지 않고 실행되면 오퍼레이터가 즉시 제거됩니다."
},
"safetyPreamble": "안전 규칙 (수정 불가)",
"safetyPreambleHint": "항상 앞에 추가되며, 도구가 반환하는 모든 것을 신뢰할 수 없는 데이터로 다루도록 오퍼레이터에게 지시합니다.",
@@ -3937,11 +3939,9 @@
"saving": "설정을 저장하는 중…",
"verifying-gate": "승인 게이트를 확인하는 중…",
"done": "완료",
- "canary": "오퍼레이터가 플랫폼에 접근할 수 있는지 확인하는 중…",
- "write-canary": "실제 테스트 쓰기를 실행 중입니다. 승인을 기다려야 합니다…",
- "longWait1": "두 연결 검사가 병렬로 실행 중입니다 — 가장 오래 걸리는 단계입니다…",
- "longWait2": "새 오퍼레이터와 실제 대화를 실행 중 — 모델이 생각하고 있습니다…",
- "longWait3": "계속 진행 중 — 느린 제공업체에서는 최대 1분이 걸릴 수 있습니다…"
+ "longWait1": "오퍼레이터 에이전트를 생성하고 배포하는 중입니다 — 가장 오래 걸리는 단계입니다…",
+ "longWait2": "새 에이전트에 도구와 승인 게이트를 설정하는 중입니다…",
+ "longWait3": "아직 진행 중입니다 — 바쁜 플랫폼에서는 배포에 시간이 걸릴 수 있습니다…"
},
"status": {
"title": "오퍼레이터",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "플랫폼 오퍼레이터를 활성화했습니다",
- "activatedReadWrite": "플랫폼 오퍼레이터가 활성화되었습니다 — 쓰기 권한이 확인되었습니다",
- "activatedReadWriteUnverified": "플랫폼 오퍼레이터 활성화됨 — 승인 게이트 검증됨; 실시간 쓰기 프로브는 결론에 이르지 못했습니다",
"deactivated": "플랫폼 오퍼레이터를 비활성화했습니다",
"reset": "플랫폼 오퍼레이터를 삭제했습니다",
- "activatedButUnreachable": "오퍼레이터를 배포했지만 플랫폼을 읽지 못했습니다"
+ "activatedButUnreachable": "오퍼레이터를 배포했지만 플랫폼을 읽지 못했습니다",
+ "activatedGateVerified": "플랫폼 오퍼레이터 활성화됨 — 승인 게이트 검증 완료. 연결 확인이 백그라운드에서 실행 중입니다.",
+ "activatedChecking": "플랫폼 오퍼레이터 활성화됨. 연결 확인이 백그라운드에서 실행 중입니다.",
+ "writeProbeVerified": "쓰기 액세스 검증됨 — 실제 게이트 적용 쓰기가 승인을 위해 일시 중지되었습니다.",
+ "writeProbeFailed": "승인 게이트가 작동하지 않았습니다 — 오퍼레이터가 제거되었습니다.",
+ "writeProbeInconclusive": "라이브 쓰기 프로브가 결론에 이르지 못했습니다."
},
"canary": {
"genericFailure": "연결 확인에 실패했습니다.",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index a2165834..9c0cd9a7 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -703,7 +703,10 @@
"toolResult": "Resultado",
"error": "Ocorreu um erro",
"toolCallsCount_one": "{{count}} chamada de ferramenta",
- "stepsCount": "{{count}} etapas", "stepsCount_one": "{{count}} etapa", "stepsCount_many": "{{count}} etapas", "stepsCount_other": "{{count}} etapas",
+ "stepsCount": "{{count}} etapas",
+ "stepsCount_one": "{{count}} etapa",
+ "stepsCount_many": "{{count}} etapas",
+ "stepsCount_other": "{{count}} etapas",
"toolCallsCount_many": "{{count}} chamadas de ferramentas",
"toolCallsCount_other": "{{count}} chamadas de ferramentas",
"toolCallsCount": "{{count}} chamadas de ferramentas",
@@ -3936,7 +3939,7 @@
"label": "Leitura e escrita",
"description": "Também permite criar e alterar agentes e grupos de agentes, implementar, remover a implementação, desativar uma agenda fora de controlo e editar o descritor de um agente — cada uma à espera da sua aprovação primeiro."
},
- "writeWarning": "Ativar irá executar um teste canário de escrita: uma escrita de teste real e inofensiva que tem de ficar em pausa à espera de aprovação antes de este deployment terminar. Se não pausar, a ativação é recusada e o operador é removido em vez de ficar implementado com um gate de escrita não verificado."
+ "writeWarning": "A ativação verifica o portão de aprovação contra a política armazenada antes de concluir. Uma escrita de teste real e inofensiva é então executada em segundo plano — se algum dia for executada sem pausar para aprovação, o operador é removido imediatamente."
},
"safetyPreamble": "Regras de segurança (não editáveis)",
"safetyPreambleHint": "São sempre antepostas. Instruem o operador a tratar tudo o que as suas ferramentas devolvem como dados não fiáveis.",
@@ -3966,11 +3969,9 @@
"saving": "A guardar a configuração…",
"verifying-gate": "A verificar a barreira de aprovação…",
"done": "Concluído",
- "canary": "A verificar se o operador consegue aceder à sua plataforma…",
- "write-canary": "A executar uma escrita de teste real, que tem de aguardar a sua aprovação…",
- "longWait1": "As duas verificações de conexão executam em paralelo — esta é a etapa mais longa…",
- "longWait2": "Uma conversa real está em andamento com o novo operador — o modelo está pensando…",
- "longWait3": "Ainda em andamento — pode levar até um minuto em provedores mais lentos…"
+ "longWait1": "O agente operador está sendo criado e implantado — esta é a etapa mais longa…",
+ "longWait2": "Configurando as ferramentas e o portão de aprovação no novo agente…",
+ "longWait3": "Ainda em andamento — a implantação pode demorar em uma plataforma ocupada…"
},
"status": {
"title": "Operador",
@@ -4055,11 +4056,14 @@
},
"toast": {
"activated": "Operador da plataforma ativado",
- "activatedReadWrite": "Platform Operator ativado — acesso de escrita verificado",
- "activatedReadWriteUnverified": "Operador de plataforma ativado — porta de aprovação verificada; a sonda de escrita ao vivo foi inconclusiva",
"deactivated": "Operador da plataforma desativado",
"reset": "Operador da plataforma eliminado",
- "activatedButUnreachable": "Operador implantado, mas não conseguiu ler a sua plataforma"
+ "activatedButUnreachable": "Operador implantado, mas não conseguiu ler a sua plataforma",
+ "activatedGateVerified": "Operador de plataforma ativado — portão de aprovação verificado. As verificações de conexão estão em execução em segundo plano.",
+ "activatedChecking": "Operador de plataforma ativado. As verificações de conexão estão em execução em segundo plano.",
+ "writeProbeVerified": "Acesso de escrita verificado — uma escrita real protegida pausou para aprovação.",
+ "writeProbeFailed": "O portão de aprovação não resistiu — o operador foi removido.",
+ "writeProbeInconclusive": "A sonda de escrita ao vivo foi inconclusiva."
},
"canary": {
"genericFailure": "A verificação de ligação não foi bem-sucedida.",
diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json
index d590b51b..b8518530 100644
--- a/src/i18n/locales/th.json
+++ b/src/i18n/locales/th.json
@@ -695,7 +695,9 @@
"toolResult": "ผลลัพธ์",
"error": "เกิดข้อผิดพลาด",
"toolCallsCount_one": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
- "stepsCount": "{{count}} ขั้นตอน", "stepsCount_one": "{{count}} ขั้นตอน", "stepsCount_other": "{{count}} ขั้นตอน",
+ "stepsCount": "{{count}} ขั้นตอน",
+ "stepsCount_one": "{{count}} ขั้นตอน",
+ "stepsCount_other": "{{count}} ขั้นตอน",
"toolCallsCount_other": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
"toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
"usingTool": "กำลังใช้ {{tool}}…"
@@ -3907,7 +3909,7 @@
"label": "อ่านและเขียน",
"description": "ยังสามารถสร้างและแก้ไขเอเจนต์และกลุ่มเอเจนต์ ปรับใช้ ยกเลิกการปรับใช้ ปิดใช้งานตารางเวลาที่ควบคุมไม่ได้ และแก้ไขคำอธิบายของเอเจนต์ — แต่ละอย่างจะรอการอนุมัติจากคุณก่อน"
},
- "writeWarning": "การเปิดใช้งานจะรันการทดสอบเขียนแบบคานารี: การเขียนทดสอบจริงที่ไม่เป็นอันตราย ซึ่งต้องหยุดรอการอนุมัติก่อนที่การปรับใช้นี้จะเสร็จสิ้น หากไม่หยุดชั่วคราว การเปิดใช้งานจะถูกปฏิเสธ และผู้ดูแลจะถูกลบออก แทนที่จะปล่อยให้ปรับใช้อยู่พร้อมประตูการเขียนที่ยังไม่ได้ตรวจสอบ"
+ "writeWarning": "การเปิดใช้งานจะตรวจสอบเกตอนุมัติกับนโยบายที่จัดเก็บไว้ก่อนเสร็จสิ้น จากนั้นการเขียนทดสอบจริงที่ไม่เป็นอันตรายจะทำงานในเบื้องหลัง — หากมันถูกดำเนินการโดยไม่หยุดรอการอนุมัติ โอเปอเรเตอร์จะถูกลบออกทันที"
},
"safetyPreamble": "กฎความปลอดภัย (แก้ไขไม่ได้)",
"safetyPreambleHint": "จะถูกใส่ไว้ด้านหน้าเสมอ โดยกำหนดให้ผู้ดูแลถือว่าทุกสิ่งที่เครื่องมือส่งกลับมาเป็นข้อมูลที่เชื่อถือไม่ได้",
@@ -3937,11 +3939,9 @@
"saving": "กำลังบันทึกการตั้งค่า…",
"verifying-gate": "กำลังตรวจสอบเกตอนุมัติ…",
"done": "เสร็จสิ้น",
- "canary": "กำลังตรวจสอบว่าผู้ดูแลเข้าถึงแพลตฟอร์มของคุณได้…",
- "write-canary": "กำลังรันการเขียนทดสอบจริง ซึ่งต้องรอการอนุมัติจากคุณ…",
- "longWait1": "การตรวจสอบการเชื่อมต่อทั้งสองทำงานพร้อมกัน — นี่คือขั้นตอนที่ใช้เวลานานที่สุด…",
- "longWait2": "กำลังรันการสนทนาจริงกับตัวดำเนินการใหม่ — โมเดลกำลังคิด…",
- "longWait3": "ยังทำงานอยู่ — อาจใช้เวลาถึงหนึ่งนาทีกับผู้ให้บริการที่ช้ากว่า…"
+ "longWait1": "กำลังสร้างและปรับใช้เอเจนต์โอเปอเรเตอร์ — นี่คือขั้นตอนที่ใช้เวลานานที่สุด…",
+ "longWait2": "กำลังตั้งค่าเครื่องมือและเกตอนุมัติบนเอเจนต์ใหม่…",
+ "longWait3": "ยังทำงานอยู่ — การปรับใช้อาจใช้เวลาสักครู่บนแพลตฟอร์มที่มีงานมาก…"
},
"status": {
"title": "ผู้ดูแล",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "เปิดใช้งานผู้ดูแลแพลตฟอร์มแล้ว",
- "activatedReadWrite": "เปิดใช้งาน Platform Operator แล้ว — ตรวจสอบสิทธิ์การเขียนแล้ว",
- "activatedReadWriteUnverified": "เปิดใช้งานตัวดำเนินการแพลตฟอร์มแล้ว — ประตูการอนุมัติได้รับการยืนยัน โพรบเขียนสดไม่สามารถสรุปผลได้",
"deactivated": "ปิดใช้งานผู้ดูแลแพลตฟอร์มแล้ว",
"reset": "ลบผู้ดูแลแพลตฟอร์มแล้ว",
- "activatedButUnreachable": "ดีพลอยผู้ดูแลแล้ว แต่ยังอ่านข้อมูลแพลตฟอร์มของคุณไม่ได้"
+ "activatedButUnreachable": "ดีพลอยผู้ดูแลแล้ว แต่ยังอ่านข้อมูลแพลตฟอร์มของคุณไม่ได้",
+ "activatedGateVerified": "เปิดใช้งานโอเปอเรเตอร์แพลตฟอร์มแล้ว — ตรวจสอบเกตอนุมัติแล้ว การตรวจสอบการเชื่อมต่อกำลังทำงานในเบื้องหลัง",
+ "activatedChecking": "เปิดใช้งานโอเปอเรเตอร์แพลตฟอร์มแล้ว การตรวจสอบการเชื่อมต่อกำลังทำงานในเบื้องหลัง",
+ "writeProbeVerified": "ตรวจสอบสิทธิ์การเขียนแล้ว — การเขียนจริงที่ถูกป้องกันหยุดรอการอนุมัติ",
+ "writeProbeFailed": "เกตอนุมัติไม่ทำงาน — โอเปอเรเตอร์ถูกลบออกแล้ว",
+ "writeProbeInconclusive": "การทดสอบการเขียนแบบสดไม่ได้ข้อสรุป"
},
"canary": {
"genericFailure": "การตรวจสอบการเชื่อมต่อไม่สำเร็จ",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 8329572f..d4009d3b 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -695,7 +695,9 @@
"toolResult": "结果",
"error": "发生错误",
"toolCallsCount_one": "{{count}} 次工具调用",
- "stepsCount": "{{count}} 个步骤", "stepsCount_one": "{{count}} 个步骤", "stepsCount_other": "{{count}} 个步骤",
+ "stepsCount": "{{count}} 个步骤",
+ "stepsCount_one": "{{count}} 个步骤",
+ "stepsCount_other": "{{count}} 个步骤",
"toolCallsCount_other": "{{count}} 次工具调用",
"toolCallsCount": "{{count}} 次工具调用",
"usingTool": "正在使用 {{tool}}…"
@@ -3907,7 +3909,7 @@
"label": "读写",
"description": "还可以创建和修改智能体与智能体群组、部署、取消部署、禁用失控的计划任务,以及编辑智能体的描述——每一项都会先等待你的批准。"
},
- "writeWarning": "激活将运行一次写入金丝雀测试:一次真实、无害的测试写入,必须在此部署完成前等待批准。如果它没有暂停,激活将被拒绝,操作员将被移除,而不是带着未经验证的写入关卡继续部署。"
+ "writeWarning": "激活会在完成前根据已存储的策略验证审批门。随后会在后台执行一次真实且无害的测试写入 — 如果它在未暂停等待审批的情况下被执行,操作员将被立即移除。"
},
"safetyPreamble": "安全规则(不可编辑)",
"safetyPreambleHint": "始终置于最前。它要求操作员将工具返回的一切内容视为不可信数据。",
@@ -3937,11 +3939,9 @@
"saving": "正在保存配置……",
"verifying-gate": "正在验证审批门禁…",
"done": "完成",
- "canary": "正在检查操作员能否访问你的平台……",
- "write-canary": "正在执行一次真实的测试写入,需等待你的批准……",
- "longWait1": "两项连接检查正在并行运行 — 这是耗时最长的步骤…",
- "longWait2": "正在与新操作员进行真实对话 — 模型正在思考…",
- "longWait3": "仍在进行中 — 在较慢的提供商上可能需要一分钟…"
+ "longWait1": "正在构建并部署操作员代理 — 这是耗时最长的一步…",
+ "longWait2": "正在为新代理配置工具和审批门…",
+ "longWait3": "仍在进行中 — 在繁忙的平台上部署可能需要一些时间…"
},
"status": {
"title": "操作员",
@@ -4026,11 +4026,14 @@
},
"toast": {
"activated": "平台操作员已启用",
- "activatedReadWrite": "平台操作员已激活——写入权限已验证",
- "activatedReadWriteUnverified": "平台操作员已激活 — 审批门已验证;实时写入探测未能得出结论",
"deactivated": "平台操作员已停用",
"reset": "平台操作员已删除",
- "activatedButUnreachable": "操作员已部署,但无法读取你的平台"
+ "activatedButUnreachable": "操作员已部署,但无法读取你的平台",
+ "activatedGateVerified": "平台操作员已激活 — 审批门已验证。连接检查正在后台运行。",
+ "activatedChecking": "平台操作员已激活。连接检查正在后台运行。",
+ "writeProbeVerified": "写入权限已验证 — 一次受审批门保护的真实写入已暂停等待审批。",
+ "writeProbeFailed": "审批门未能拦截 — 操作员已被移除。",
+ "writeProbeInconclusive": "实时写入探测未得出结论。"
},
"canary": {
"genericFailure": "连接检查未通过。",
diff --git a/src/index.css b/src/index.css
index e27255dd..7c344fb4 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,9 @@
@import 'tailwindcss';
+/* Real typography for the `prose` classes every markdown surface already
+ wears. Without this plugin they were inert: preflight strips heading sizes,
+ list bullets and margins, and nothing put them back — chat replies rendered
+ as a crowded wall with unstyled tables. */
+@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -194,6 +199,32 @@
text-decoration: underline;
}
+ /* Markdown tables inside chat bubbles: readable and self-contained. The
+ typography plugin provides the base table styling; these add the chat
+ specifics — hairline row borders, breathing room in cells, and horizontal
+ scrolling INSIDE the table so a wide model-written table can never force
+ the whole bubble (or page) to scroll sideways. */
+ .prose table {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ border-collapse: collapse;
+ }
+
+ .prose th {
+ text-align: start;
+ font-weight: 600;
+ padding: 0.375rem 0.75rem;
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-muted);
+ }
+
+ .prose td {
+ padding: 0.375rem 0.75rem;
+ border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
+ vertical-align: top;
+ }
+
/* ── Onboarding ─────────────────────────────── */
/* Spotlight overlay — box-shadow dims everything except the cutout rect */
diff --git a/src/lib/api/chat.ts b/src/lib/api/chat.ts
index 096647c3..d7967821 100644
--- a/src/lib/api/chat.ts
+++ b/src/lib/api/chat.ts
@@ -35,6 +35,7 @@ export type SSEEventType =
| "task_start"
| "task_complete"
| "task_failed"
+ | "tool_call"
| "cascade_step_start"
| "cascade_escalation"
| "done"
diff --git a/src/lib/operator/__tests__/write-canary.test.ts b/src/lib/operator/__tests__/write-canary.test.ts
index cea198ab..7f65866c 100644
--- a/src/lib/operator/__tests__/write-canary.test.ts
+++ b/src/lib/operator/__tests__/write-canary.test.ts
@@ -3,7 +3,8 @@ import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import {
runOperatorWriteCanary,
- enforceWriteCanaryGate,
+ enforceGateDryRun,
+ runBackgroundWriteProbe,
buildWriteCanaryPrompt,
WRITE_CANARY_TARGET_ENDPOINT,
} from "../write-canary";
@@ -323,40 +324,186 @@ describe("buildWriteCanaryPrompt", () => {
});
});
-describe("enforceWriteCanaryGate", () => {
+describe("enforceGateDryRun — the blocking, deterministic half", () => {
const VAR_URL = "*/variablestore/variables/default/platform.operator";
- /** Default: the deterministic check verifies the stored policy. Individual
- * tests override this to drive the not-gated / old-backend / error paths. */
- const dryRunGated = () =>
- http.post("*/administration/operator/gate-dry-run", () =>
- HttpResponse.json({ policyPresent: true, gated: true, matchedPattern: "http.patch:*" }),
+ beforeEach(() => {
+ server.use(
+ http.post("*/administration/operator/canary-result", () => new HttpResponse(null, { status: 204 })),
+ http.post("*/agents/:conversationId/endConversation", () => new HttpResponse(null, { status: 200 })),
+ );
+ });
+
+ it("is a no-op for read_only — nothing is called, nothing is deleted", async () => {
+ let dryRunCalled = false;
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () => {
+ dryRunCalled = true;
+ return HttpResponse.json({ policyPresent: true, gated: true, matchedPattern: "http.patch:*" });
+ }),
+ );
+
+ const result = await enforceGateDryRun(config({ scope: "read_only" }), spec());
+
+ expect(result).toBeNull();
+ expect(dryRunCalled).toBe(false);
+ });
+
+ it("returns true — verified — when the dry-run classifies the target write as gated", async () => {
+ let deleted = false;
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () =>
+ HttpResponse.json({ policyPresent: true, gated: true, matchedPattern: "http.patch:*" }),
+ ),
+ http.delete("*/agentstore/agents/:id", () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 200 });
+ }),
+ );
+
+ await expect(enforceGateDryRun(config(), spec())).resolves.toBe(true);
+ expect(deleted).toBe(false);
+ });
+
+ /**
+ * Deterministically broken configuration: the one write-verification outcome
+ * that still blocks activation, because it is PROOF, not absence of proof.
+ */
+ it("rolls back and throws when the dry-run says the write is not gated", async () => {
+ let undeployed = false;
+ let deleted = false;
+ let configCleared = false;
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () =>
+ HttpResponse.json({ policyPresent: true, gated: false, matchedPattern: null }),
+ ),
+ http.post("*/administration/:env/undeploy/:agentId", () => {
+ undeployed = true;
+ return new HttpResponse(null, { status: 200 });
+ }),
+ http.delete("*/agentstore/agents/:id", ({ request }) => {
+ deleted = true;
+ // resetOperator's full-wipe semantics: cascade + permanent.
+ expect(request.url).toContain("cascade=true");
+ expect(request.url).toContain("permanent=true");
+ return new HttpResponse(null, { status: 200 });
+ }),
+ http.delete(VAR_URL, () => {
+ configCleared = true;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ const error = String(await enforceGateDryRun(config(), spec()).catch((e: unknown) => e));
+
+ expect(error).toMatch(/did NOT hold/);
+ expect(error).toMatch(/no probe was run and nothing was written/i);
+ expect(undeployed).toBe(true);
+ expect(deleted).toBe(true);
+ expect(configCleared).toBe(true);
+ // Pins the RollbackFailure re-throw guard: without it the rollback's own
+ // throw is caught again and re-wrapped, so the admin reads a generic
+ // "could not verify / deterministic check failed" headline instead of the
+ // proven-broken-gate one (and the operator is rolled back twice).
+ expect(error).not.toMatch(/deterministic check failed/i);
+ expect(error).not.toMatch(/could not verify/i);
+ });
+
+ it("fails closed when the dry-run itself errors (not 404) — verification failure, not breach", async () => {
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () => HttpResponse.json({ message: "boom" }, { status: 500 })),
+ http.delete("*/agentstore/agents/:id", () => new HttpResponse(null, { status: 200 })),
+ http.delete(VAR_URL, () => new HttpResponse(null, { status: 204 })),
+ http.post("*/administration/:env/undeploy/:agentId", () => new HttpResponse(null, { status: 200 })),
+ );
+
+ const error = String(await enforceGateDryRun(config(), spec()).catch((e: unknown) => e));
+
+ expect(error).toMatch(/could not verify the approval gate/i);
+ expect(error).toMatch(/deterministic check failed/i);
+ expect(error).not.toMatch(/did NOT hold/);
+ // An admin left with no operator needs a way forward, not just a verdict.
+ expect(error).toMatch(/try activating again/i);
+ expect(error).toMatch(/read-only/i);
+ });
+
+ /**
+ * A backend that predates gate-dry-run (404) can no longer be verified
+ * deterministically — activation proceeds UNVERIFIED (false) rather than
+ * failing, and the background probe becomes the deployment's only evidence.
+ */
+ it("returns false — unverified, not broken — on a 404 old backend, and deletes nothing", async () => {
+ let deleted = false;
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () => new HttpResponse(null, { status: 404 })),
+ http.delete("*/agentstore/agents/:id", () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 200 });
+ }),
+ );
+
+ await expect(enforceGateDryRun(config(), spec())).resolves.toBe(false);
+ expect(deleted).toBe(false);
+ });
+
+ it("says the operator is STILL DEPLOYED when the rollback itself fails", async () => {
+ server.use(
+ http.post("*/administration/operator/gate-dry-run", () =>
+ HttpResponse.json({ policyPresent: false, gated: false, matchedPattern: null }),
+ ),
+ http.post("*/administration/:env/undeploy/:agentId", () => new HttpResponse(null, { status: 200 })),
+ // The DELETE, not the undeploy: resetOperator deliberately tolerates a
+ // failed undeploy (already undeployed is fine — deletion is the point),
+ // so only a failed delete actually leaves the agent standing.
+ http.delete("*/agentstore/agents/:id", () =>
+ HttpResponse.json({ message: "backend down" }, { status: 500 }),
+ ),
+ );
+
+ const error = String(await enforceGateDryRun(config(), spec()).catch((e: unknown) => e));
+
+ expect(error).toMatch(/still deployed/i);
+ expect(error).toMatch(/remove it manually/i);
+ // The original reason must survive too — the admin needs both facts.
+ expect(error).toMatch(/did NOT hold/);
+ });
+});
+
+describe("runBackgroundWriteProbe — the empirical half, after activation", () => {
+ const VAR_URL = "*/variablestore/variables/default/platform.operator";
+
+ /** The stored config still names the probe's agent — the teardown's happy precondition. */
+ const storedConfigPointsAt = (agentId: string) =>
+ http.get(VAR_URL, () =>
+ HttpResponse.json({
+ key: "platform.operator",
+ value: JSON.stringify(config({ agentId })),
+ }),
);
beforeEach(() => {
server.use(
- dryRunGated(),
http.post("*/administration/operator/canary-result", () => new HttpResponse(null, { status: 204 })),
http.post("*/agents/:conversationId/endConversation", () => new HttpResponse(null, { status: 200 })),
);
});
it("is a no-op for read_only — no probe runs, nothing is deleted", async () => {
- let anyWriteCanaryRequestMade = false;
+ let anyProbeRequestMade = false;
server.use(
http.post("*/agents/:agentId/start", () => {
- anyWriteCanaryRequestMade = true;
+ anyProbeRequestMade = true;
return HttpResponse.json({ location: "/agents/conv-1" }, { status: 201 });
}),
);
- const result = await enforceWriteCanaryGate(config({ scope: "read_only" }), spec());
+ const report = await runBackgroundWriteProbe(config({ scope: "read_only" }), spec(), true);
- expect(result).toBeNull();
- expect(anyWriteCanaryRequestMade).toBe(false);
+ expect(report).toBeNull();
+ expect(anyProbeRequestMade).toBe(false);
});
- it("returns the passing result and deletes nothing when the canary passes", async () => {
+ it("reports a clean pass and deletes nothing when the probe's write pauses", async () => {
serveTurn([
taskComplete([{ type: "tool_call", tool: "patchDescriptor" }]),
doneWith("AWAITING_HUMAN"),
@@ -381,16 +528,18 @@ describe("enforceWriteCanaryGate", () => {
}),
);
- const result = await enforceWriteCanaryGate(config(), spec());
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
- expect(result?.outcome).toBe("pass");
+ expect(report?.result.outcome).toBe("pass");
+ expect(report?.tornDown).toBe(false);
expect(deleteCalled).toBe(false);
});
- it("rolls the agent back and throws when the canary does not pass — the actual safety property", async () => {
+ it("tears the operator down — without throwing — when the write executes without pausing", async () => {
// The write executes without pausing — the gate is broken. This is the
- // scenario the whole rollback exists for: an agent that is ALREADY
- // deployed, right now, with a write tool that just proved unsafe.
+ // scenario the teardown exists for: an agent that is ALREADY deployed,
+ // right now, with a write tool that just proved unsafe. The probe runs in
+ // the background, so the verdict arrives as a report, not a throw.
serveTurn([
taskComplete([
{ type: "tool_call", tool: "patchDescriptor" },
@@ -402,13 +551,13 @@ describe("enforceWriteCanaryGate", () => {
let deleted = false;
let configCleared = false;
server.use(
+ storedConfigPointsAt("op-1"),
http.post("*/administration/:env/undeploy/:agentId", () => {
undeployed = true;
return new HttpResponse(null, { status: 200 });
}),
http.delete("*/agentstore/agents/:id", ({ request }) => {
deleted = true;
- // resetOperator's full-wipe semantics: cascade + permanent.
expect(request.url).toContain("cascade=true");
expect(request.url).toContain("permanent=true");
return new HttpResponse(null, { status: 200 });
@@ -419,100 +568,28 @@ describe("enforceWriteCanaryGate", () => {
}),
);
- await expect(enforceWriteCanaryGate(config(), spec())).rejects.toThrow(/did NOT hold/);
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
+ expect(report?.result.outcome).toBe("fail");
+ expect(report?.tornDown).toBe(true);
+ expect(report?.message).toMatch(/did NOT hold/);
+ expect(report?.message).toMatch(/was removed/i);
+ // Nudging a retry at a broken gate is the one thing this must never do.
+ expect(report?.message).not.toMatch(/try activating again/i);
+ expect(report?.message).toMatch(/do not re-activate with write access/i);
expect(undeployed).toBe(true);
expect(deleted).toBe(true);
expect(configCleared).toBe(true);
});
/**
- * THE core semantic change of the dry-run integration: an operator whose
- * stored policy verified deterministically is not deleted just because the
- * model declined to attempt the probe's write. That deletion was the original
- * defect — activation as a coin flip on an LLM's tool choice.
+ * The probe runs detached from activation, so a breach verdict can land
+ * AFTER the operator was reconfigured (this page, another tab, another
+ * admin). A stale probe must remove its own agent — the breach is real —
+ * but clearing the shared config variable would erase the REPLACEMENT
+ * operator's config.
*/
- it("proceeds — does NOT roll back — when the policy verified and the probe was merely inconclusive", async () => {
- serveTurn(["event: token\ndata: nothing useful\n\n", doneWith("READY")]);
- let deleted = false;
- server.use(http.delete("*/agentstore/agents/:id", () => { deleted = true; return new HttpResponse(null, { status: 200 }); }));
-
- const result = await enforceWriteCanaryGate(config(), spec());
-
- expect(deleted).toBe(false);
- expect(result?.outcome).toBe("unknown");
- // Honest, not upgraded: the caller sees exactly what was and wasn't proven.
- expect(result?.error).toMatch(/verified deterministically/i);
- expect(result?.error).toMatch(/probe was inconclusive/i);
- });
-
- /**
- * Deterministically broken configuration: the probe is NOT run — provoking a
- * write against a policy known not to gate it would execute it for real.
- */
- it("rolls back without running the probe when the dry-run says the write is not gated", async () => {
- server.use(
- http.post("*/administration/operator/gate-dry-run", () =>
- HttpResponse.json({ policyPresent: true, gated: false, matchedPattern: null }),
- ),
- http.delete("*/agentstore/agents/:id", () => new HttpResponse(null, { status: 200 })),
- );
- let probeStarted = false;
- server.use(
- http.post("*/agents/:agentId/start", () => {
- probeStarted = true;
- return HttpResponse.json({ location: "/agents/conv-1" }, { status: 201 });
- }),
- );
-
- const error = String(await enforceWriteCanaryGate(config(), spec()).catch((e: unknown) => e));
-
- expect(probeStarted).toBe(false);
- expect(error).toMatch(/did NOT hold/);
- expect(error).toMatch(/no probe was run and nothing was written/i);
- // Pins the RollbackFailure re-throw guard: without it the rollback's own
- // throw is caught again and re-wrapped, so the admin reads a generic
- // "could not verify / deterministic check failed" headline instead of the
- // proven-broken-gate one (and the operator is rolled back twice).
- expect(error).not.toMatch(/deterministic check failed/i);
- expect(error).not.toMatch(/could not verify/i);
- });
-
- /**
- * A backend that predates gate-dry-run (404) restores the old semantics
- * wholesale: with nothing verified, "unknown" must keep rolling back — "not
- * proven safe" stays the bar when there is no other evidence.
- */
- it("still rolls back an inconclusive probe against an old backend without gate-dry-run", async () => {
- server.use(http.post("*/administration/operator/gate-dry-run", () => new HttpResponse(null, { status: 404 })));
- serveTurn(["event: token\ndata: nothing useful\n\n", doneWith("READY")]);
- let deleted = false;
- server.use(http.delete("*/agentstore/agents/:id", () => { deleted = true; return new HttpResponse(null, { status: 200 }); }));
-
- const error = String(await enforceWriteCanaryGate(config(), spec()).catch((e: unknown) => e));
-
- expect(deleted).toBe(true);
- expect(error).toMatch(/not evidence that it is broken/i);
- expect(error).not.toMatch(/did NOT hold/);
- // An admin left with no operator needs a way forward, not just a verdict.
- expect(error).toMatch(/try activating again/i);
- expect(error).toMatch(/read-only/i);
- });
-
- it("fails closed when the dry-run itself errors (not 404) — verification failure, not breach", async () => {
- server.use(
- http.post("*/administration/operator/gate-dry-run", () => HttpResponse.json({ message: "boom" }, { status: 500 })),
- http.delete("*/agentstore/agents/:id", () => new HttpResponse(null, { status: 200 })),
- );
-
- const error = String(await enforceWriteCanaryGate(config(), spec()).catch((e: unknown) => e));
-
- expect(error).toMatch(/could not verify the approval gate/i);
- expect(error).toMatch(/deterministic check failed/i);
- expect(error).not.toMatch(/did NOT hold/);
- });
-
- it("says the gate did NOT hold — and does not offer a retry — on a confirmed fail", async () => {
+ it("a stale probe removes its own agent but leaves a successor's config untouched", async () => {
serveTurn([
taskComplete([
{ type: "tool_call", tool: "patchDescriptor" },
@@ -520,17 +597,35 @@ describe("enforceWriteCanaryGate", () => {
]),
doneWith("READY"),
]);
- server.use(http.delete("*/agentstore/agents/:id", () => new HttpResponse(null, { status: 200 })));
+ let deleted = false;
+ let configCleared = false;
+ server.use(
+ // The stored config now names a DIFFERENT agent — this probe is stale.
+ storedConfigPointsAt("op-2-replacement"),
+ http.post("*/administration/:env/undeploy/:agentId", () => new HttpResponse(null, { status: 200 })),
+ http.delete("*/agentstore/agents/:id", ({ request }) => {
+ deleted = true;
+ expect(request.url).toContain("/agents/op-1?");
+ expect(request.url).toContain("cascade=true");
+ expect(request.url).toContain("permanent=true");
+ return new HttpResponse(null, { status: 200 });
+ }),
+ http.delete(VAR_URL, () => {
+ configCleared = true;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
- const error = String(await enforceWriteCanaryGate(config(), spec()).catch((e: unknown) => e));
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
- expect(error).toMatch(/did NOT hold/);
- // Nudging a retry at a broken gate is the one thing this must never do.
- expect(error).not.toMatch(/try activating again/i);
- expect(error).toMatch(/do not re-activate with write access/i);
+ expect(report?.tornDown).toBe(true);
+ expect(deleted).toBe(true);
+ expect(configCleared).toBe(false);
+ expect(report?.message).toMatch(/no longer points at it/i);
+ expect(report?.message).toMatch(/left untouched/i);
});
- it("the thrown error names the outcome and carries the canary's own error detail", async () => {
+ it("never clears the shared config on a guess — an unreadable store still only removes the probe's agent", async () => {
serveTurn([
taskComplete([
{ type: "tool_call", tool: "patchDescriptor" },
@@ -538,16 +633,31 @@ describe("enforceWriteCanaryGate", () => {
]),
doneWith("READY"),
]);
- server.use(http.delete("*/agentstore/agents/:id", () => new HttpResponse(null, { status: 200 })));
+ let deleted = false;
+ let configCleared = false;
+ server.use(
+ http.get(VAR_URL, () => HttpResponse.json({ message: "store down" }, { status: 500 })),
+ http.post("*/administration/:env/undeploy/:agentId", () => new HttpResponse(null, { status: 200 })),
+ http.delete("*/agentstore/agents/:id", () => {
+ deleted = true;
+ return new HttpResponse(null, { status: 200 });
+ }),
+ http.delete(VAR_URL, () => {
+ configCleared = true;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
- await expect(enforceWriteCanaryGate(config(), spec())).rejects.toThrow(/executed without pausing/i);
+ expect(report?.tornDown).toBe(true);
+ expect(deleted).toBe(true);
+ expect(configCleared).toBe(false);
+ expect(report?.message).toMatch(/could not be read/i);
+ expect(report?.message).toMatch(/check the operator screen/i);
});
- it("says the operator is STILL DEPLOYED when the rollback itself fails", async () => {
- // The one path the admin has to act on. Letting the rollback's own error
- // propagate would surface a bare transport message for what is actually
- // "a write-capable operator that failed its gate check is still live" —
- // read as a retryable blip, and the agent is never removed.
+ it("says the operator is STILL DEPLOYED when the teardown itself fails", async () => {
serveTurn([
taskComplete([
{ type: "tool_call", tool: "patchDescriptor" },
@@ -556,20 +666,60 @@ describe("enforceWriteCanaryGate", () => {
doneWith("READY"),
]);
server.use(
- // The DELETE, not the undeploy: resetOperator deliberately tolerates a
- // failed undeploy (already undeployed is fine — deletion is the point),
- // so only a failed delete actually leaves the agent standing.
+ storedConfigPointsAt("op-1"),
+ http.post("*/administration/:env/undeploy/:agentId", () => new HttpResponse(null, { status: 200 })),
http.delete("*/agentstore/agents/:id", () =>
HttpResponse.json({ message: "backend down" }, { status: 500 }),
),
);
- const error = await enforceWriteCanaryGate(config(), spec()).catch((e: unknown) => e);
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
- expect(String(error)).toMatch(/still deployed/i);
- expect(String(error)).toMatch(/remove it manually/i);
- // The original reason must survive too — the admin needs both facts.
- expect(String(error)).toMatch(/did NOT hold/);
+ expect(report?.tornDown).toBe(false);
+ expect(report?.message).toMatch(/still deployed/i);
+ expect(report?.message).toMatch(/remove it manually/i);
+ expect(report?.message).toMatch(/did NOT hold/);
+ });
+
+ /**
+ * THE core semantic property carried over from the dry-run integration: an
+ * operator whose stored policy verified deterministically is not deleted
+ * just because the model declined to attempt the probe's write.
+ */
+ it("reports — does NOT tear down — when the policy verified and the probe was merely inconclusive", async () => {
+ serveTurn(["event: token\ndata: nothing useful\n\n", doneWith("READY")]);
+ let deleted = false;
+ server.use(http.delete("*/agentstore/agents/:id", () => { deleted = true; return new HttpResponse(null, { status: 200 }); }));
+
+ const report = await runBackgroundWriteProbe(config(), spec(), true);
+
+ expect(deleted).toBe(false);
+ expect(report?.result.outcome).toBe("unknown");
+ expect(report?.tornDown).toBe(false);
+ // Honest, not upgraded: the caller sees exactly what was and wasn't proven.
+ expect(report?.message).toMatch(/verified deterministically/i);
+ expect(report?.message).toMatch(/probe was inconclusive/i);
+ });
+
+ /**
+ * The deliberate semantic CHANGE from the blocking era: on an old backend
+ * (no gate-dry-run, nothing verified) an inconclusive probe used to roll the
+ * activation back. Now that the probe runs after activation, absence of
+ * proof is reported as an honest warning — only PROOF of a broken gate
+ * (outcome "fail") tears down a deployed operator.
+ */
+ it("warns — does NOT tear down — when nothing verified and the probe was inconclusive", async () => {
+ serveTurn(["event: token\ndata: nothing useful\n\n", doneWith("READY")]);
+ let deleted = false;
+ server.use(http.delete("*/agentstore/agents/:id", () => { deleted = true; return new HttpResponse(null, { status: 200 }); }));
+
+ const report = await runBackgroundWriteProbe(config(), spec(), false);
+
+ expect(deleted).toBe(false);
+ expect(report?.result.outcome).toBe("unknown");
+ expect(report?.tornDown).toBe(false);
+ expect(report?.message).toMatch(/not evidence that it is broken/i);
+ expect(report?.message).toMatch(/does not support the deterministic check/i);
});
});
diff --git a/src/lib/operator/system-prompt.ts b/src/lib/operator/system-prompt.ts
index 3b988016..50041c1a 100644
--- a/src/lib/operator/system-prompt.ts
+++ b/src/lib/operator/system-prompt.ts
@@ -278,7 +278,12 @@ const BODY_STYLE = `Personality and formatting:
⚠️ needs attention, ❌ broken or failed, 💡 suggestion. A handful per answer
at most: they are road signs, not decoration.
- Short question, short answer: one sentence needs no headings, no emoji, and
- no overview.`;
+ no overview.
+- Write STRICT Markdown. Emphasis delimiters hug their text with no space
+ inside them (\`**bold**\`, never \`**bold **\` or \`** bold**\` — a space inside
+ renders the asterisks literally). Tables need a header row, a |---|
+ separator line, and one row per line. Keep tables to a few short columns;
+ move long prose out of cells and into the surrounding text.`;
/**
* Appended only when writes are granted.
diff --git a/src/lib/operator/write-canary.ts b/src/lib/operator/write-canary.ts
index 023d0913..0c78c13d 100644
--- a/src/lib/operator/write-canary.ts
+++ b/src/lib/operator/write-canary.ts
@@ -3,11 +3,13 @@ import { resumeConversation, getApprovalStatus } from "@/lib/api/hitl";
import {
gateDryRun,
isNotFound,
+ readOperatorConfig,
reportOperatorCanaryResult,
resetOperator,
type OperatorConfig,
type FetchedSpec,
} from "@/lib/api/operator";
+import { undeployAgent, deleteAgent } from "@/lib/api/agents";
import { buildOperationIdIndex, resolveToolNameForEndpoint } from "./reconstruct-endpoint";
/**
@@ -347,123 +349,210 @@ async function handlePause(
}
/**
- * Runs the write canary against a just-activated `read_write` operator and
- * enforces its result — the actual grant decision, not just the probe.
+ * The BLOCKING half of write verification: deterministic classification of the
+ * probe's target call against the operator's STORED policy, via the backend's
+ * gate-dry-run endpoint (the same ToolApprovalGate.classify the tool loop runs
+ * at execution time). Pure function of policy + call address: cannot flake,
+ * writes nothing — which is why it is the only write check activation still
+ * waits on. The empirical LLM probe moved to {@link runBackgroundWriteProbe}:
+ * it costs a full model conversation per run and its "unknown" outcomes say
+ * nothing about the gate, so blocking (or worse, rolling back) on it made
+ * activation slow and flaky without adding proof.
*
- * A failed read canary or failed gate verification (see `useActivateOperator`)
- * is reported but non-fatal: an inert or unreachable operator is merely
- * useless. A failed write canary is different in kind. `config` is already
- * DEPLOYED at the point this runs — provisioning happens before any probe —
- * so a non-"pass" outcome means live write tools that just proved they do not
- * pause are reachable RIGHT NOW. Reporting that and moving on would leave them
- * reachable; this rolls the whole activation back instead.
+ * No-op — returns `null` — for any scope other than `read_write`.
*
- * `resetOperator` (undeploy, delete, clear the config variable) rather than
- * merely discarding the caller's local config object: `config` was already
- * persisted by the caller before this runs, so anything short of clearing the
- * stored variable would leave it pointing at an agent this function just
- * deleted.
- *
- * No-op — returns `null` — for any scope other than `read_write`: a read_only
- * agent has no write tool this probe could provoke, and running it anyway
- * would report "unknown" uselessly on every activation.
- *
- * @throws if the canary did not pass — after rollback, or, if rollback ALSO
- * failed, with a message saying so explicitly.
+ * @returns whether the policy was deterministically verified: `false` means
+ * the backend predates gate-dry-run (404) or the target tool could
+ * not be resolved from the spec — NOT that the gate is broken.
+ * @throws after rolling the activation back when the dry-run proves the policy
+ * does not gate the target write, or when verification itself fails
+ * (fail closed — not proven safe, not deployed).
*/
-export async function enforceWriteCanaryGate(
+export async function enforceGateDryRun(
config: OperatorConfig,
spec: FetchedSpec,
- signal?: AbortSignal,
-): Promise {
+): Promise {
if (config.scope !== "read_write") return null;
- // ── Check 1: deterministic classification (backend gate-dry-run) ──
- //
- // The backend classifies the canary's exact target call against the operator's
- // STORED policy, using the same ToolApprovalGate.classify the tool loop runs
- // at execution time. Pure function of policy + call address: cannot flake,
- // writes nothing. This is what breaks the old coin flip — with the policy
- // deterministically verified, an inconclusive empirical probe no longer has to
- // be treated as a possible security failure.
- let policyVerified = false;
const expectedToolName = resolveToolNameForEndpoint(WRITE_CANARY_TARGET_ENDPOINT, buildOperationIdIndex(spec));
- if (expectedToolName) {
- try {
- const dryRun = await gateDryRun(config, expectedToolName, WRITE_CANARY_TARGET_ENDPOINT);
- if (!dryRun.gated) {
- // Deterministically broken configuration. The empirical probe is NOT run:
- // provoking the write against a policy known not to gate it would execute
- // the write for real — the destructive path, entered knowingly.
- const why = dryRun.policyPresent
- ? "the stored approval policy does not gate the canary's own target write"
- : "the agent document carries no approval policy at all";
- await rollBack(
- config,
- `The approval gate did NOT hold: ${why} (verified deterministically against the stored ` +
- "agent document — no probe was run and nothing was written). Do not re-activate with " +
- "write access until the gate is fixed.",
- );
- }
- policyVerified = true;
- } catch (error) {
- if (error instanceof RollbackFailure) {
- throw error;
- }
- if (!isNotFound(error)) {
- // Not "old backend", an actual failure to verify. Fail closed — same
- // principle as everywhere else in this flow: not proven safe, not
- // deployed. The message says it is a verification failure, not a breach.
- const detail = error instanceof Error ? error.message : String(error);
- await rollBack(
- config,
- `Could not verify the approval gate (the deterministic check failed: ${detail}) — this is ` +
- "not evidence that it is broken. Try activating again, or choose read-only access, " +
- "which needs no write verification.",
- );
- }
- // 404 → the backend predates gate-dry-run. policyVerified stays false and
- // the empirical probe below carries the full burden, exactly as before.
+ if (!expectedToolName) return false;
+
+ try {
+ const dryRun = await gateDryRun(config, expectedToolName, WRITE_CANARY_TARGET_ENDPOINT);
+ if (!dryRun.gated) {
+ // Deterministically broken configuration — the one write-verification
+ // outcome that must still block activation, because it is PROOF, not
+ // absence of proof.
+ const why = dryRun.policyPresent
+ ? "the stored approval policy does not gate the canary's own target write"
+ : "the agent document carries no approval policy at all";
+ await rollBack(
+ config,
+ `The approval gate did NOT hold: ${why} (verified deterministically against the stored ` +
+ "agent document — no probe was run and nothing was written). Do not re-activate with " +
+ "write access until the gate is fixed.",
+ );
}
+ return true;
+ } catch (error) {
+ if (error instanceof RollbackFailure) {
+ throw error;
+ }
+ if (!isNotFound(error)) {
+ // Not "old backend", an actual failure to verify. Fail closed — same
+ // principle as everywhere else in this flow: not proven safe, not
+ // deployed. The message says it is a verification failure, not a breach.
+ const detail = error instanceof Error ? error.message : String(error);
+ await rollBack(
+ config,
+ `Could not verify the approval gate (the deterministic check failed: ${detail}) — this is ` +
+ "not evidence that it is broken. Try activating again, or choose read-only access, " +
+ "which needs no write verification.",
+ );
+ }
+ // 404 → the backend predates gate-dry-run. Report unverified; the
+ // background probe is the only evidence this deployment will get.
+ return false;
+ }
+}
+
+/** What the background write probe concluded, shaped for direct UI surfacing. */
+export interface WriteProbeReport {
+ result: WriteCanaryResult;
+ /**
+ * True when the probe PROVED the gate broken (its write executed without
+ * pausing) and the operator was therefore removed. The one outcome that
+ * still ends the deployment — it is proof, arriving late.
+ */
+ tornDown: boolean;
+ /** Human-readable summary for the admin; always set for non-pass outcomes. */
+ message?: string;
+}
+
+/**
+ * The BACKGROUND half of write verification: the empirical probe that provokes
+ * one real gated write and checks it pauses. Runs AFTER activation has
+ * completed — the admin is already chatting with the operator while this
+ * verifies. Never throws.
+ *
+ * Outcome handling differs from the old blocking gate on exactly one point:
+ * an "unknown" no longer rolls anything back. Unknown means the model never
+ * attempted the write (or the outcome could not be observed) — absence of
+ * proof. Deleting a deployed, deterministically-verified operator over that
+ * was the original defect the dry-run fixed; now that activation no longer
+ * waits on this probe, unknown is reported as a warning instead. A "fail" is
+ * still PROOF the gate is broken with write tools reachable right now, so it
+ * still tears the operator down — see {@link tearDownBreachedOperator} for
+ * why the shared config variable is only cleared when it still names this
+ * probe's agent.
+ */
+export async function runBackgroundWriteProbe(
+ config: OperatorConfig,
+ spec: FetchedSpec,
+ policyVerified: boolean,
+ signal?: AbortSignal,
+): Promise {
+ if (config.scope !== "read_write") return null;
+
+ let result: WriteCanaryResult;
+ try {
+ result = await runOperatorWriteCanary(config, spec, signal);
+ } catch (error) {
+ // runOperatorWriteCanary reports failures as outcomes rather than throwing;
+ // this catch is a guard against its internals changing, not a real path.
+ result = {
+ outcome: "unknown",
+ toolCalls: 0,
+ error: error instanceof Error ? error.message : String(error),
+ durationMs: 0,
+ };
}
- // ── Check 2: empirical probe (the gate actually pausing a real call) ──
- const result = await runOperatorWriteCanary(config, spec, signal);
if (result.outcome === "pass") {
- return result;
+ return { result, tornDown: false };
}
- if (result.outcome === "unknown" && policyVerified) {
- // The policy is deterministically sound; the model merely never attempted
- // the write, which proves nothing about the gate. Deleting a verified
- // operator over that was the original defect. Proceed, but return the
- // outcome honestly — the caller surfaces it — rather than upgrading it to
- // a pass the probe never earned.
- return {
- ...result,
- error:
- "The stored approval policy was verified deterministically (gate-dry-run: the canary's target " +
+ if (result.outcome === "fail") {
+ // The probe's write EXECUTED without pausing. The gate is broken at
+ // runtime, whatever the stored policy says — remove the operator.
+ const reason = `The approval gate did NOT hold: ${result.error ?? "no further detail"} Do not re-activate with write access until the gate is fixed.`;
+ const teardown = await tearDownBreachedOperator(config, reason);
+ return { result, ...teardown };
+ }
+
+ return {
+ result,
+ tornDown: false,
+ message: policyVerified
+ ? "The stored approval policy was verified deterministically (gate-dry-run: the probe's target " +
"write classifies as gated), but the live probe was inconclusive — the operator did not attempt " +
- `the write. ${result.error ?? ""}`.trim(),
- };
+ `the write. ${result.error ?? ""}`.trim()
+ : "Could not verify the approval gate empirically, and this backend does not support the " +
+ `deterministic check — this is not evidence that it is broken. ${result.error ?? ""}`.trim(),
+ };
+}
+
+/**
+ * Removes a probe-proven-unsafe operator WITHOUT clobbering a successor.
+ *
+ * This probe runs detached from activation, so by the time a breach is proven
+ * the operator may already have been reconfigured — by this page, another tab,
+ * or another admin — and the stored `platform.operator` variable can point at
+ * a REPLACEMENT agent. `resetOperator` deletes the agent and then
+ * unconditionally clears that shared variable; run stale, it would erase the
+ * replacement's config. So the shared variable is cleared only when the stored
+ * config still names this probe's agent. A stale probe (or one that cannot
+ * READ the stored config — never clear shared state on a guess) removes its
+ * own agent and leaves the shared state alone.
+ */
+async function tearDownBreachedOperator(
+ config: OperatorConfig,
+ reason: string,
+): Promise<{ tornDown: boolean; message: string }> {
+ let stillCurrent = false;
+ let storeUnreadable = false;
+ try {
+ stillCurrent = (await readOperatorConfig())?.agentId === config.agentId;
+ } catch {
+ storeUnreadable = true;
}
- // fail → the probe's write EXECUTED without pausing. The gate is broken
- // at runtime, whatever the stored policy says.
- // unknown → nothing was learned AND the policy could not be verified
- // (old backend without gate-dry-run) — the pre-dry-run semantics.
- const failure =
- result.outcome === "fail"
- ? `The approval gate did NOT hold: ${result.error ?? "no further detail"}`
- : `Could not verify the approval gate — this is not evidence that it is broken. ` +
- `${result.error ?? "No further detail."}`;
- const nextStep =
- result.outcome === "fail"
- ? "Do not re-activate with write access until the gate is fixed."
- : "Try activating again, or choose read-only access, which needs no write probe.";
- await rollBack(config, `${failure} ${nextStep}`);
- // rollBack always throws; this is unreachable but satisfies the compiler.
- return result;
+ try {
+ if (stillCurrent) {
+ await resetOperator(config);
+ return {
+ tornDown: true,
+ message: `${reason} The operator was removed rather than left deployed with a broken write gate.`,
+ };
+ }
+ // Stale (or unverifiable): remove only THIS probe's agent.
+ if (config.agentId && config.version != null) {
+ try {
+ await undeployAgent(config.environment, config.agentId, config.version, {
+ endAllActiveConversations: true,
+ });
+ } catch {
+ // Already undeployed, or the environment is gone — deletion is what matters.
+ }
+ await deleteAgent(config.agentId, config.version, { cascade: true, permanent: true });
+ }
+ return {
+ tornDown: true,
+ message: storeUnreadable
+ ? `${reason} The probed agent was removed, but the stored operator config could not be read to ` +
+ "confirm it still points at this agent, so it was left untouched — check the operator screen."
+ : `${reason} The probed agent was removed. The stored operator config no longer points at it ` +
+ "(the operator was reconfigured or removed since this probe started), so it was left untouched.",
+ };
+ } catch (rollbackError) {
+ const detail = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
+ return {
+ tornDown: false,
+ message:
+ `${reason} Removing it ALSO failed (${detail}). The operator is still deployed with ` +
+ "write tools and a broken gate — remove it manually from the operator screen now.",
+ };
+ }
}
/** Marker so the dry-run catch can re-throw a rollback's error untouched. */
diff --git a/src/pages/operator.tsx b/src/pages/operator.tsx
index 19acf76e..4e758232 100644
--- a/src/pages/operator.tsx
+++ b/src/pages/operator.tsx
@@ -25,6 +25,8 @@ import {
useOperatorCanary,
useVerifyOperatorGate,
seedConfig,
+ runPostActivationProbes,
+ operatorKeys,
type ActivationStage,
} from "@/hooks/use-operator";
import { useOperatorChat } from "@/hooks/use-operator-chat";
@@ -174,26 +176,61 @@ export function OperatorPage() {
setShowActivation(false);
// The predecessor agent was hard-deleted; its conversation id is dead.
chat.reset();
- if (outcome.canary.ok) {
- setCanaryWarning(null);
- // A reachable onSuccess means the write canary either did not run
- // (read_only) or passed — a non-"pass" result throws, landing in
- // onError below with the agent already rolled back.
- toast.success(
- // "verified" is earned only by a passing probe. An "unknown"
- // outcome survives activation (the deterministic dry-run held),
- // but the live probe was inconclusive — say that, not "verified".
- outcome.writeCanary?.outcome === "pass"
- ? t("operator.toast.activatedReadWrite", "Platform Operator activated — write access verified")
- : outcome.writeCanary
- ? t("operator.toast.activatedReadWriteUnverified",
- "Platform Operator activated — approval gate verified; the live write probe was inconclusive")
- : t("operator.toast.activated", "Platform Operator activated"),
- );
- } else {
- setCanaryWarning(outcome.canary.error ?? t("operator.canary.genericFailure", "The connection check did not succeed."));
- toast.warning(t("operator.toast.activatedButUnreachable", "Operator deployed, but it could not read your platform"));
- }
+ // Activation now ends at the deterministic checks — the operator is
+ // live and usable RIGHT NOW. The LLM probes (read canary + live
+ // write probe) verify in the background and report as they land.
+ setCanaryWarning(null);
+ toast.success(
+ outcome.config.scope === "read_write" && outcome.policyVerified
+ ? t("operator.toast.activatedGateVerified",
+ "Platform Operator activated — approval gate verified. Connection checks are running in the background.")
+ : t("operator.toast.activatedChecking",
+ "Platform Operator activated. Connection checks are running in the background."),
+ );
+ void runPostActivationProbes(outcome, {
+ onReadResult: (result) => {
+ if (result.ok) {
+ setCanaryWarning(null);
+ } else {
+ setCanaryWarning(
+ result.error ?? t("operator.canary.genericFailure", "The connection check did not succeed."),
+ );
+ toast.warning(
+ t("operator.toast.activatedButUnreachable", "Operator deployed, but it could not read your platform"),
+ );
+ }
+ },
+ onWriteResult: (report) => {
+ if (report.result.outcome === "pass") {
+ toast.success(
+ t("operator.toast.writeProbeVerified", "Write access verified — a real gated write paused for approval."),
+ );
+ return;
+ }
+ if (report.tornDown || report.result.outcome === "fail") {
+ // Proven breach (or a breach whose teardown failed): this is
+ // a failure state, not a warning — surface it where a failed
+ // activation would land and re-read what the server now has.
+ // The toast carries the report's OWN disposition: a failed
+ // teardown says "still deployed — remove it manually", and a
+ // fixed "was removed" here would falsely reassure the admin
+ // (this toast can be the only visible result after
+ // navigation, since the activation form is already closed).
+ const message =
+ report.message ??
+ t("operator.toast.writeProbeFailed", "The approval gate did not hold — the operator was removed.");
+ setActivationError(message);
+ toast.error(message);
+ void queryClient.invalidateQueries({ queryKey: operatorKeys.all });
+ return;
+ }
+ // Inconclusive: absence of proof, reported honestly but quietly.
+ toast.warning(
+ report.message ??
+ t("operator.toast.writeProbeInconclusive", "The live write probe was inconclusive."),
+ );
+ },
+ });
},
onError: (err) => {
setStage("idle");
@@ -410,6 +447,7 @@ export function OperatorPage() {