Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuesti
import { readAskUserQuestionDeadlineAt } from "@liveagent/ui/lib/chat/askUserQuestion";
import type { PlanDecisionAnswer } from "@liveagent/ui/lib/chat/planMode";
import { readPlanApprovedMarker, readPlanPendingMarker } from "@liveagent/ui/lib/chat/planMode";
import { readToolApprovalPending } from "@liveagent/ui/lib/chat/toolApprovalArgs";
import {
readToolApprovalPending,
readToolPermissionAutoResolved,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";
import { useSyncExternalStore } from "react";
import { submitAskUserQuestionAnswer } from "../lib/chat/askUserQuestionBridge";
import {
Expand All @@ -22,6 +25,13 @@ export function usePendingToolApproval(
return readToolApprovalPending(toolArguments);
}

export function usePermissionAutoResolved(
_toolCallId: string,
toolArguments: Record<string, unknown>,
) {
return readToolPermissionAutoResolved(toolArguments);
}

export function readAskUserQuestionDeadline(
_toolCallId: string,
toolArguments: Record<string, unknown>,
Expand Down
44 changes: 24 additions & 20 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveSearchJumpRowKey,
subscribeSearchJumpTarget,
} from "@liveagent/ui/lib/chat/searchJumpTarget";
import { buildToolApprovalRequestJson } from "@liveagent/ui/lib/chat/toolApprovalArgs";
import { useMentionApps } from "@liveagent/ui/lib/chat/useMentionApps";
import { useScrollFollow } from "@liveagent/ui/lib/chat-scroll/useScrollFollow";
import {
Expand Down Expand Up @@ -810,26 +811,29 @@ function useGatewayAppController() {
registerToolApprovalDecisionHandler(null);
return;
}
registerToolApprovalDecisionHandler(async (toolCallId, decision, targetConversationId) => {
// 背景 Pane 显式携带自己的会话 id;主视图缺省仍按当前展示会话路由。
const conversationIdValue = (
targetConversationId?.trim() ||
resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current)
).trim();
if (!conversationIdValue) {
return { ok: false, message: "No active conversation." };
}
try {
const response = await api.chatQueueToolApproval(
conversationIdValue,
toolCallId,
JSON.stringify({ decision }),
);
return { ok: response.accepted, message: response.message || undefined };
} catch (error) {
return { ok: false, message: asErrorMessage(error, "Failed to submit the decision.") };
}
});
registerToolApprovalDecisionHandler(
async (toolCallId, decision, targetConversationId, options) => {
// 背景 Pane 显式携带自己的会话 id;主视图缺省仍按当前展示会话路由。
const conversationIdValue = (
targetConversationId?.trim() ||
resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current)
).trim();
if (!conversationIdValue) {
return { ok: false, message: "No active conversation." };
}
try {
// 旧桌面端只读 decision,多出的 scope/reason 会被忽略。
const response = await api.chatQueueToolApproval(
conversationIdValue,
toolCallId,
buildToolApprovalRequestJson(decision, options),
);
return { ok: response.accepted, message: response.message || undefined };
} catch (error) {
return { ok: false, message: asErrorMessage(error, "Failed to submit the decision.") };
}
},
);
return () => registerToolApprovalDecisionHandler(null);
}, [api]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ export function useGatewayChatPresentation({
pendingToolApprovals.length > 0 ? (
<ToolApprovalBar
pending={pendingToolApprovals}
onDecide={(toolCallId, decision) => submitToolApprovalDecision(toolCallId, decision)}
onDecide={(toolCallId, decision, options) =>
submitToolApprovalDecision(toolCallId, decision, undefined, options)
}
onDecideAll={async (decision) => {
for (const item of pendingToolApprovals) {
await submitToolApprovalDecision(item.toolCallId, decision);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -784,8 +784,8 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr
pendingToolApprovals.length > 0 ? (
<ToolApprovalBar
pending={pendingToolApprovals}
onDecide={(toolCallId, decision) =>
submitToolApprovalDecision(toolCallId, decision, conversationId)
onDecide={(toolCallId, decision, options) =>
submitToolApprovalDecision(toolCallId, decision, conversationId, options)
}
onDecideAll={async (decision) => {
for (const item of pendingToolApprovals) {
Expand Down
12 changes: 9 additions & 3 deletions crates/agent-gateway/web/src/lib/chat/toolApprovalBridge.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
// WebUI 端工具审批桥:审批卡片位于 transcript 深处,提交动作由 GatewayApp 注册
// (经 gateway chat_queue.tool_approval 送达桌面端审批挂起表)。模块级单例避免
// 跨多层组件做 props 透传,模式同 askUserQuestionBridge。
import type { ToolApprovalDecision } from "@liveagent/ui/lib/chat/toolApprovalArgs";
import type {
ToolApprovalAnswerOptions,
ToolApprovalDecision,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";

export type ToolApprovalSubmitOutcome = { ok: boolean; message?: string };

type ToolApprovalDecisionHandler = (
toolCallId: string,
decision: ToolApprovalDecision,
conversationId?: string,
options?: ToolApprovalAnswerOptions,
) => Promise<ToolApprovalSubmitOutcome>;

let handler: ToolApprovalDecisionHandler | null = null;
Expand All @@ -19,15 +23,17 @@ export function registerToolApprovalDecisionHandler(next: ToolApprovalDecisionHa

/**
* conversationId 缺省时按"当前展示会话"路由(主视图);多看板的背景 Pane
* 必须显式传自己的会话 id,避免审批被误提交到焦点会话。
* 必须显式传自己的会话 id,避免审批被误提交到焦点会话。options 带"允许并记住"的
* 作用域或拒绝说明。
*/
export function submitToolApprovalDecision(
toolCallId: string,
decision: ToolApprovalDecision,
conversationId?: string,
options?: ToolApprovalAnswerOptions,
): Promise<ToolApprovalSubmitOutcome> {
if (!handler) {
return Promise.resolve({ ok: false, message: "Gateway connection is not ready." });
}
return handler(toolCallId, decision, conversationId);
return handler(toolCallId, decision, conversationId, options);
}
17 changes: 17 additions & 0 deletions crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
answerAskUserQuestion,
getAskUserQuestionDeadlineAt,
} from "../lib/tools/askUserQuestionTools";
import {
getPermissionAutoResolved,
getPermissionAutoResolvedVersion,
subscribePermissionAutoResolved,
} from "../lib/tools/permissionAutoResolved";
import {
answerPlanDecision,
getPlanDecisionVersion,
Expand All @@ -29,6 +34,18 @@ export function usePendingToolApproval(
return Boolean(getPendingToolApproval(toolCallId));
}

export function usePermissionAutoResolved(
toolCallId: string,
_toolArguments: Record<string, unknown>,
) {
useSyncExternalStore(
subscribePermissionAutoResolved,
getPermissionAutoResolvedVersion,
getPermissionAutoResolvedVersion,
);
return getPermissionAutoResolved(toolCallId);
}

export function readAskUserQuestionDeadline(
toolCallId: string,
_toolArguments: Record<string, unknown>,
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gui/src/lib/kbrain/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,7 @@ export function createKBrainClient(inputOptions: KBrainClientOptions = {}) {
decision: "allow_once" | "allow_always" | "reject",
runId = "",
reason?: string,
scope?: string,
): Promise<void> {
await request<unknown>(
`/v1/sessions/${encodeURIComponent(conversationId)}/permissions/${encodeURIComponent(permissionId)}`,
Expand All @@ -882,6 +883,7 @@ export function createKBrainClient(inputOptions: KBrainClientOptions = {}) {
permission_id: permissionId,
decision,
...(reason ? { reason } : {}),
...(scope ? { scope } : {}),
},
}),
},
Expand Down
113 changes: 94 additions & 19 deletions crates/agent-gui/src/lib/kbrain/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export function contextToKBrainMessages(context: Context): KBrainMessage[] {
return messages;
}

/** How many times a prompt K-brain kept waiting after a refused answer is asked again. */
const PERMISSION_ANSWER_RETRIES = 3;

/** permission.requested payload. `rule` scopes an allow_always answer, which K-brain only
* remembers when it lists that option; older backends offer allow_once/reject only. */
export type KBrainPermissionRequest = {
Expand All @@ -148,6 +151,26 @@ export type KBrainPermissionRequest = {
scope?: string;
/** The model tool call waiting on this decision, when K-brain knows it. */
tool_call_id?: string;
/** Shell the command runs in (bash, pwsh, cmd); empty for non-shell tools. */
shell?: string;
/** What allow_always would remember, from backends with permission_rules. */
suggestion?: { rules: string[]; default_scope?: string };
};

/** The answer to a permission request: a bare decision, or one with the remember
* scope (allow_always) or the reason given to the model (reject). */
export type KBrainPermissionAnswer =
| KBrainPermissionDecision
| { decision: KBrainPermissionDecision; scope?: string; reason?: string };

export type KBrainPermissionDecision = "allow_once" | "allow_always" | "reject";

/** permission.auto_resolved: the backend allowed a call without asking. */
export type KBrainPermissionAutoResolved = {
toolCallId: string;
source: "rule" | "read_only";
rule?: string;
scope?: string;
};

export type KBrainTurnParams = {
Expand All @@ -174,7 +197,8 @@ export type KBrainTurnParams = {
onPermissionRequest?: (
request: KBrainPermissionRequest,
signal?: AbortSignal,
) => Promise<"allow_once" | "allow_always" | "reject">;
) => Promise<KBrainPermissionAnswer>;
onPermissionAutoResolved?: (resolved: KBrainPermissionAutoResolved) => void;
applyConversationState?: (state: ConversationViewState) => void;
getConversationState?: () => ConversationViewState;
baseUrl?: string;
Expand Down Expand Up @@ -900,25 +924,40 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
}
const permissionController = new AbortController();
pendingPermissions.set(request.permission_id, permissionController);
let decisionPromise: Promise<"allow_once" | "allow_always" | "reject">;
try {
decisionPromise = Promise.resolve(
params.onPermissionRequest(request, permissionController.signal),
);
} catch (error) {
decisionPromise = Promise.reject(error);
}
void decisionPromise
.then((decision) => {
const onPermissionRequest = params.onPermissionRequest;
const answerPermission = async () => {
for (let attempt = 0; ; attempt += 1) {
const answer = await onPermissionRequest(request, permissionController.signal);
if (permissionController.signal.aborted) return;
return client
.resolvePermission(kbrainSessionId, request.permission_id, decision, accepted.run_id)
.catch((error) => {
// A concurrent session grant can consume this waiter before its SSE resolution arrives.
if ((error as { status?: number }).status === 404) return;
throw error;
});
})
const { decision, scope, reason } = permissionAnswerFields(answer);
try {
await client.resolvePermission(
kbrainSessionId,
request.permission_id,
decision,
accepted.run_id,
reason,
scope,
);
return;
} catch (error) {
const status = (error as { status?: number }).status;
// A concurrent session grant can consume this waiter before its SSE resolution arrives.
if (status === 404) return;
// K-brain answers 400 and keeps the prompt waiting when it cannot store the
// remembered rules (a scope it cannot keep, a failed write): ask again.
if (
status === 400 &&
attempt < PERMISSION_ANSWER_RETRIES &&
!permissionController.signal.aborted
) {
continue;
}
throw error;
}
}
};
void answerPermission()
.catch((error) => {
if (permissionController.signal.aborted) return;
permissionFailure = error instanceof Error ? error : new Error(String(error));
Expand All @@ -932,6 +971,25 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
});
break;
}
case "permission.auto_resolved": {
const resolved = eventPayload<{
tool_call_id?: string;
source?: string;
rule?: string;
scope?: string;
}>(event);
const toolCallId = resolved?.tool_call_id?.trim();
const source = resolved?.source;
if (toolCallId && (source === "rule" || source === "read_only")) {
params.onPermissionAutoResolved?.({
toolCallId,
source,
...(resolved?.rule ? { rule: resolved.rule } : {}),
...(resolved?.scope ? { scope: resolved.scope } : {}),
});
}
break;
}
case "permission.resolved": {
const resolution = eventPayload<{ permission_id?: string }>(event);
if (resolution?.permission_id) {
Expand Down Expand Up @@ -1050,3 +1108,20 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
}
return message;
}

// Scope only goes with allow_always and a reason only with reject, so a stray field
// cannot widen or explain a different decision.
function permissionAnswerFields(answer: KBrainPermissionAnswer): {
decision: KBrainPermissionDecision;
scope?: string;
reason?: string;
} {
if (typeof answer === "string") return { decision: answer };
const scope = answer.decision === "allow_always" ? answer.scope?.trim() : undefined;
const reason = answer.decision === "reject" ? answer.reason?.trim() : undefined;
return {
decision: answer.decision,
...(scope ? { scope } : {}),
...(reason ? { reason } : {}),
};
}
47 changes: 47 additions & 0 deletions crates/agent-gui/src/lib/tools/permissionAutoResolved.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// K-brain 没有询问就放行的工具调用(permission.auto_resolved):只读操作或命中已记住
// 的规则。桌面端在这里按 toolCallId 记下来源,工具行据此显示小标记;同步给 WebUI 的
// 参数由 gatewayToolPreview 叠加同一标记。只存在内存里:标记只说明这一次运行发生了
// 什么,重新加载后不再显示。

import {
normalizeToolPermissionAutoResolved,
type ToolPermissionAutoResolved,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";

// 一次会话里自动放行的调用可以很多;只保留最近的,旧的标记随之淡出。
const MAX_ENTRIES = 1000;

const byToolCallId = new Map<string, ToolPermissionAutoResolved>();
const listeners = new Set<() => void>();
let version = 0;

export function recordPermissionAutoResolved(toolCallId: string, value: unknown): boolean {
const key = toolCallId.trim();
const normalized = normalizeToolPermissionAutoResolved(value);
if (!key || !normalized) return false;
byToolCallId.delete(key);
byToolCallId.set(key, normalized);
while (byToolCallId.size > MAX_ENTRIES) {
const oldest = byToolCallId.keys().next().value;
if (oldest === undefined) break;
byToolCallId.delete(oldest);
}
version += 1;
for (const listener of listeners) listener();
return true;
}

export function getPermissionAutoResolved(toolCallId: string): ToolPermissionAutoResolved | null {
return byToolCallId.get(toolCallId.trim()) ?? null;
}

export function subscribePermissionAutoResolved(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function getPermissionAutoResolvedVersion(): number {
return version;
}
Loading
Loading