From 2335ae28050d9e788b1e1edff02da69d630bfd06 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 4 May 2026 05:18:51 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20refactor:=20Align=20HITL=20Surfa?= =?UTF-8?q?ce=20with=20Agents=20SDK=20Permissions=20Model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes Slice A on top of the agents SDK's now-landed HITL surface (`createToolPolicyHook`, discriminated `HumanInterruptPayload`, `'bypass'` mode naming). Host stops reimplementing evaluation logic and becomes a config mapper + payload wrapper. Schema (data-provider): - `toolApproval` shape now mirrors SDK `ToolPolicyConfig` 1:1: `mode: 'default' | 'dontAsk' | 'bypass'`, plus `allow` / `deny` / `ask` glob lists and an optional `reason` template. `enabled` is the LibreChat-only admin kill switch. - `'bypass'` (not `'bypassPermissions'`) — matches the SDK's surface. Types (`Agents.*` namespace): - `HumanInterruptType` extended to `'tool_approval' | 'ask_user_question'`. - `HumanInterruptPayload` is now a discriminated union — `tool_approval` carries `action_requests` + `review_configs`; `ask_user_question` carries a free-form question with optional curated options. - New: `AskUserQuestionRequest`, `AskUserQuestionOption`, `AskUserQuestionResolution`. - `ToolApprovalDecision` (string union) renamed to `ToolApprovalDecisionType` to free the `Decision` name for the SDK's discriminated object union later. - `ToolApprovalResolution` gains `reason?` and `scope?: 'once' | 'session' | 'always'` so route signatures stabilize before persistence lands. Policy module (`packages/api/src/agents/hitl/policy.ts`): - Drop `decideToolApproval` / `requiresApproval` / `ToolRef` — the SDK's `createToolPolicyHook` handles full evaluation (`deny → bypass → allow → ask → dontAsk → fallthrough(ask)`). - Add `isHITLEnabled(policy)` — the kill-switch predicate that gates the SDK's `humanInTheLoop: { enabled: false }` opt-out in Slice B. - Add `mapToolApprovalPolicy(policy)` — strips `enabled`, returns a `ToolPolicyConfig` to feed `createToolPolicyHook`. Structural mirror of the SDK type so this compiles before the SDK upgrade ships. - Reshape `buildPendingAction(payload, ctx)` to wrap any `HumanInterruptPayload` with job context — accepts SDK output directly. - Add `buildToolApprovalPayload(...)` and `buildAskUserQuestionPayload(...)` helpers for synthesizing payloads in tests / pre-SDK flows. Tests: - 22 new unit tests covering the mapper, predicate, and payload builders; 20 → 27 total pass across policy + manager-lifecycle suites. --- packages/api/src/agents/hitl/policy.spec.ts | 235 ++++++++++++------ packages/api/src/agents/hitl/policy.ts | 180 ++++++++------ .../stream/__tests__/pendingAction.spec.ts | 13 +- packages/data-provider/src/config.ts | 47 ++-- packages/data-provider/src/types/agents.ts | 90 +++++-- 5 files changed, 371 insertions(+), 194 deletions(-) diff --git a/packages/api/src/agents/hitl/policy.spec.ts b/packages/api/src/agents/hitl/policy.spec.ts index 2387d77f0f..593e2a02f1 100644 --- a/packages/api/src/agents/hitl/policy.spec.ts +++ b/packages/api/src/agents/hitl/policy.spec.ts @@ -1,125 +1,198 @@ -import type { TToolApprovalPolicy } from 'librechat-data-provider'; -import { decideToolApproval, requiresApproval, buildPendingAction } from './policy'; +import type { Agents, TToolApprovalPolicy } from 'librechat-data-provider'; +import { + isHITLEnabled, + mapToolApprovalPolicy, + buildToolApprovalPayload, + buildAskUserQuestionPayload, + buildPendingAction, +} from './policy'; -describe('decideToolApproval', () => { - test('returns "allow" when no policy is configured', () => { - expect(decideToolApproval(undefined, { name: 'shell' })).toBe('allow'); +describe('isHITLEnabled', () => { + test('default-on when no policy configured (SDK default)', () => { + expect(isHITLEnabled(undefined)).toBe(true); }); - test('returns the configured default when no rule matches', () => { - const policy: TToolApprovalPolicy = { default: 'ask' }; - expect(decideToolApproval(policy, { name: 'unmapped' })).toBe('ask'); + test('default-on when policy is configured but `enabled` is omitted', () => { + expect(isHITLEnabled({})).toBe(true); + expect(isHITLEnabled({ mode: 'default', allow: ['read_*'] })).toBe(true); }); - test('falls back to "allow" when default is omitted', () => { - const policy: TToolApprovalPolicy = { required: ['shell'] }; - expect(decideToolApproval(policy, { name: 'web_search' })).toBe('allow'); + test('explicit false is the only off signal', () => { + expect(isHITLEnabled({ enabled: false })).toBe(false); }); - test('returns "ask" when tool is in required list', () => { - const policy: TToolApprovalPolicy = { required: ['shell', 'execute_code'] }; - expect(decideToolApproval(policy, { name: 'execute_code' })).toBe('ask'); - }); - - test('returns "allow" for tools in excluded list, even when default is "ask"', () => { - const policy: TToolApprovalPolicy = { default: 'ask', excluded: ['web_search'] }; - expect(decideToolApproval(policy, { name: 'web_search' })).toBe('allow'); - }); - - test('excluded wins over required when a tool appears in both (defensive)', () => { - const policy: TToolApprovalPolicy = { - default: 'allow', - required: ['shell'], - excluded: ['shell'], - }; - expect(decideToolApproval(policy, { name: 'shell' })).toBe('allow'); - }); - - test('returns the default when tool name is missing', () => { - const policy: TToolApprovalPolicy = { default: 'ask', required: ['shell'] }; - expect(decideToolApproval(policy, {})).toBe('ask'); + test('explicit true is on', () => { + expect(isHITLEnabled({ enabled: true })).toBe(true); }); }); -describe('requiresApproval', () => { - test('true only when decision is "ask"', () => { - const policy: TToolApprovalPolicy = { required: ['shell'] }; - expect(requiresApproval(policy, { name: 'shell' })).toBe(true); - expect(requiresApproval(policy, { name: 'web_search' })).toBe(false); - expect(requiresApproval(undefined, { name: 'shell' })).toBe(false); +describe('mapToolApprovalPolicy', () => { + test('returns undefined when no policy is configured', () => { + expect(mapToolApprovalPolicy(undefined)).toBeUndefined(); + }); + + test('returns undefined when policy is empty after stripping enabled', () => { + expect(mapToolApprovalPolicy({ enabled: true })).toBeUndefined(); + expect(mapToolApprovalPolicy({ enabled: false })).toBeUndefined(); + }); + + test('returns undefined when only empty arrays are present', () => { + expect(mapToolApprovalPolicy({ allow: [], deny: [], ask: [] })).toBeUndefined(); + }); + + test('passes through mode/allow/deny/ask/reason verbatim', () => { + const policy: TToolApprovalPolicy = { + mode: 'dontAsk', + allow: ['read_*', 'mcp:github:*'], + deny: ['delete_*'], + ask: ['execute_*'], + reason: 'Tool {tool} requires review', + }; + expect(mapToolApprovalPolicy(policy)).toEqual({ + mode: 'dontAsk', + allow: ['read_*', 'mcp:github:*'], + deny: ['delete_*'], + ask: ['execute_*'], + reason: 'Tool {tool} requires review', + }); + }); + + test('strips enabled regardless of value (LibreChat-only field)', () => { + expect(mapToolApprovalPolicy({ enabled: false, mode: 'bypass' })).toEqual({ + mode: 'bypass', + }); + expect(mapToolApprovalPolicy({ enabled: true, allow: ['read_*'] })).toEqual({ + allow: ['read_*'], + }); + }); + + test('omits empty list fields from the output', () => { + expect(mapToolApprovalPolicy({ mode: 'default', allow: [], deny: ['rm'] })).toEqual({ + mode: 'default', + deny: ['rm'], + }); + }); +}); + +describe('buildToolApprovalPayload', () => { + const calls = [ + { + name: 'shell', + arguments: { command: 'ls' }, + tool_call_id: 'call_abc', + description: 'List files', + }, + ]; + + test('produces a tool_approval-discriminated payload', () => { + const payload = buildToolApprovalPayload(calls); + expect(payload.type).toBe('tool_approval'); + expect(payload.action_requests).toEqual([ + { + name: 'shell', + arguments: { command: 'ls' }, + tool_call_id: 'call_abc', + description: 'List files', + }, + ]); + }); + + test("default decisions exclude 'respond' (reserved for AskUserQuestion semantics)", () => { + const payload = buildToolApprovalPayload(calls); + expect(payload.review_configs[0].allowed_decisions).toEqual(['approve', 'reject', 'edit']); + }); + + test('respects per-tool decision overrides', () => { + const payload = buildToolApprovalPayload(calls, { + shell: ['approve', 'reject'], + }); + expect(payload.review_configs[0].allowed_decisions).toEqual(['approve', 'reject']); + }); + + test('produces one review_config per call, in order', () => { + const payload = buildToolApprovalPayload([ + { name: 'a', arguments: {}, tool_call_id: '1' }, + { name: 'b', arguments: {}, tool_call_id: '2' }, + ]); + expect(payload.review_configs.map((r) => r.action_name)).toEqual(['a', 'b']); + }); +}); + +describe('buildAskUserQuestionPayload', () => { + test('produces an ask_user_question-discriminated payload', () => { + const payload = buildAskUserQuestionPayload({ + question: 'Which environment?', + options: [ + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + }); + expect(payload.type).toBe('ask_user_question'); + expect(payload.question.question).toBe('Which environment?'); + expect(payload.question.options).toHaveLength(2); + }); + + test('options are optional', () => { + const payload = buildAskUserQuestionPayload({ question: 'Free-form?' }); + expect(payload.question.options).toBeUndefined(); }); }); describe('buildPendingAction', () => { - const baseInput = { + const ctx = { streamId: 'stream-1', conversationId: 'conv-1', runId: 'run-1', responseMessageId: 'msg-1', - toolCalls: [ - { - name: 'shell', - arguments: { command: 'ls' }, - tool_call_id: 'call_abc', - description: 'List files', - }, - ], }; - test('produces a payload mirroring LangChain HumanInterrupt shape', () => { - const action = buildPendingAction(baseInput); - expect(action.payload.type).toBe('tool_approval'); - expect(action.payload.action_requests).toEqual([ - { - name: 'shell', - arguments: { command: 'ls' }, - tool_call_id: 'call_abc', - description: 'List files', - }, - ]); - expect(action.payload.review_configs).toEqual([ - { action_name: 'shell', allowed_decisions: ['approve', 'reject', 'edit'] }, - ]); + const toolApprovalPayload: Agents.ToolApprovalInterruptPayload = { + type: 'tool_approval', + action_requests: [{ name: 'shell', arguments: { command: 'ls' }, tool_call_id: 'call_abc' }], + review_configs: [{ action_name: 'shell', allowed_decisions: ['approve', 'reject'] }], + }; + + test('wraps a tool_approval payload with job context', () => { + const action = buildPendingAction(toolApprovalPayload, ctx); + expect(action.streamId).toBe('stream-1'); + expect(action.conversationId).toBe('conv-1'); + expect(action.runId).toBe('run-1'); + expect(action.responseMessageId).toBe('msg-1'); + expect(action.payload).toBe(toolApprovalPayload); + expect(typeof action.createdAt).toBe('number'); }); - test('respects per-tool decision overrides', () => { - const action = buildPendingAction({ - ...baseInput, - decisionsByToolName: { shell: ['approve', 'reject'] }, - }); - expect(action.payload.review_configs[0].allowed_decisions).toEqual(['approve', 'reject']); + test('wraps an ask_user_question payload with the same envelope', () => { + const askPayload: Agents.AskUserQuestionInterruptPayload = { + type: 'ask_user_question', + question: { question: 'Which env?' }, + }; + const action = buildPendingAction(askPayload, ctx); + expect(action.payload.type).toBe('ask_user_question'); }); test('generates a uuid actionId by default', () => { - const a = buildPendingAction(baseInput); - const b = buildPendingAction(baseInput); + const a = buildPendingAction(toolApprovalPayload, ctx); + const b = buildPendingAction(toolApprovalPayload, ctx); expect(a.actionId).not.toBe(b.actionId); expect(a.actionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); }); test('honours an explicit actionId', () => { - const action = buildPendingAction({ ...baseInput, actionId: 'fixed-id' }); + const action = buildPendingAction(toolApprovalPayload, { ...ctx, actionId: 'fixed-id' }); expect(action.actionId).toBe('fixed-id'); }); test('sets expiresAt only when ttlMs is provided', () => { - const without = buildPendingAction(baseInput); + const without = buildPendingAction(toolApprovalPayload, ctx); expect(without.expiresAt).toBeUndefined(); const ttl = 5_000; const before = Date.now(); - const withTtl = buildPendingAction({ ...baseInput, ttlMs: ttl }); + const withTtl = buildPendingAction(toolApprovalPayload, { ...ctx, ttlMs: ttl }); const after = Date.now(); expect(withTtl.expiresAt).toBeDefined(); expect(withTtl.expiresAt).toBeGreaterThanOrEqual(before + ttl); expect(withTtl.expiresAt).toBeLessThanOrEqual(after + ttl); }); - - test('preserves stream/conversation/run identifiers', () => { - const action = buildPendingAction(baseInput); - expect(action.streamId).toBe('stream-1'); - expect(action.conversationId).toBe('conv-1'); - expect(action.runId).toBe('run-1'); - expect(action.responseMessageId).toBe('msg-1'); - }); }); diff --git a/packages/api/src/agents/hitl/policy.ts b/packages/api/src/agents/hitl/policy.ts index 9f6516ce39..34739db31b 100644 --- a/packages/api/src/agents/hitl/policy.ts +++ b/packages/api/src/agents/hitl/policy.ts @@ -1,76 +1,125 @@ import { randomUUID } from 'crypto'; -import type { Agents, TToolApprovalPolicy, ToolApprovalDecision } from 'librechat-data-provider'; +import type { Agents, TToolApprovalPolicy } from 'librechat-data-provider'; /** - * Default decision set offered to the user for a paused tool call. + * Default decisions offered to the user for a paused tool call. * - * `approve` runs the tool as-is, `reject` blocks it with a rejection message, - * `edit` re-runs it with user-supplied arguments. `respond` is reserved for - * the future ask-user-question flow and intentionally NOT in the default set. + * `'respond'` is intentionally NOT in the default set: it represents the agent + * substituting a synthetic tool result, which is rarely the right ergonomic for + * a stock approval prompt. Hosts that want it can pass an override. */ -const DEFAULT_REVIEW_DECISIONS: Agents.ToolApprovalDecision[] = ['approve', 'reject', 'edit']; +const DEFAULT_REVIEW_DECISIONS: Agents.ToolApprovalDecisionType[] = ['approve', 'reject', 'edit']; /** - * Tool reference accepted by the policy resolver. - * Loosened from `Agents.ToolCall` so callers can pass minimal shapes - * (e.g. SDK hook payloads, MCP-derived names) without re-typing. + * Structural mirror of `@librechat/agents`'s `ToolPolicyConfig`. + * + * Defined here (rather than imported) so this module compiles before the SDK + * version that ships `createToolPolicyHook` is published. When the SDK is + * pinned, callers in Slice B can `import type { ToolPolicyConfig }` and + * the structural identity holds. */ -export interface ToolRef { - name?: string; +export interface ToolPolicyConfig { + mode?: 'default' | 'dontAsk' | 'bypass'; + allow?: string[]; + deny?: string[]; + ask?: string[]; + reason?: string; } /** - * Decide whether a tool call requires human approval, denial, or can run as-is. + * Whether the HITL machinery should run for this policy. * - * Resolution order: - * 1. `excluded` (always allow) wins over everything else. - * 2. `required` (always ask) wins over the default. - * 3. Falls back to `policy.default`, which itself defaults to `'allow'`. - * - * Returns `'allow'` when no policy is configured or the tool name is missing. + * `false` is the LibreChat-only admin kill switch — it disables the SDK + * checkpointer fallback and skips installing the policy hook entirely. + * Users wanting "stop asking me" should use `mode: 'bypass'` instead, which + * keeps the machinery in place but auto-approves. */ -export function decideToolApproval( +export function isHITLEnabled(policy: TToolApprovalPolicy | undefined): boolean { + return policy?.enabled !== false; +} + +/** + * Map a LibreChat tool-approval policy to the SDK's `ToolPolicyConfig`. + * + * Returns `undefined` when there's nothing to configure (so the SDK's own + * defaults apply). The `enabled` field is LibreChat-only and stripped here — + * it's consumed separately via {@link isHITLEnabled} to gate the SDK opt-out. + */ +export function mapToolApprovalPolicy( policy: TToolApprovalPolicy | undefined, - tool: ToolRef, -): ToolApprovalDecision { +): ToolPolicyConfig | undefined { if (!policy) { - return 'allow'; + return undefined; } - const name = tool.name; - const fallback = policy.default ?? 'allow'; - if (!name) { - return fallback; + const config: ToolPolicyConfig = {}; + if (policy.mode) { + config.mode = policy.mode; } - if (policy.excluded?.includes(name)) { - return 'allow'; + if (policy.allow && policy.allow.length > 0) { + config.allow = policy.allow; } - if (policy.required?.includes(name)) { - return 'ask'; + if (policy.deny && policy.deny.length > 0) { + config.deny = policy.deny; } - return fallback; + if (policy.ask && policy.ask.length > 0) { + config.ask = policy.ask; + } + if (policy.reason) { + config.reason = policy.reason; + } + return Object.keys(config).length > 0 ? config : undefined; } -/** Convenience wrapper. True when the tool call should be paused for human review. */ -export function requiresApproval(policy: TToolApprovalPolicy | undefined, tool: ToolRef): boolean { - return decideToolApproval(policy, tool) === 'ask'; +/** Tool-call shape consumed by {@link buildToolApprovalPayload}. */ +export interface ToolApprovalCallInput { + name: string; + arguments: string | Record; + tool_call_id: string; + description?: string; } -/** Input shape for {@link buildPendingAction}. */ -export interface BuildPendingActionInput { +/** + * Build a tool-approval interrupt payload from one or more paused tool calls. + * + * Mirrors the SDK's `ToolApprovalInterruptPayload` shape so this can be used + * to synthesize payloads in tests, or by the host before the SDK upgrade ships. + */ +export function buildToolApprovalPayload( + toolCalls: ToolApprovalCallInput[], + decisionsByToolName?: Record, +): Agents.ToolApprovalInterruptPayload { + return { + type: 'tool_approval', + action_requests: toolCalls.map((tc) => ({ + name: tc.name, + arguments: tc.arguments, + tool_call_id: tc.tool_call_id, + description: tc.description, + })), + review_configs: toolCalls.map((tc) => ({ + action_name: tc.name, + allowed_decisions: decisionsByToolName?.[tc.name] ?? DEFAULT_REVIEW_DECISIONS, + })), + }; +} + +/** Build an ask-user-question interrupt payload. */ +export function buildAskUserQuestionPayload( + question: Agents.AskUserQuestionRequest, +): Agents.AskUserQuestionInterruptPayload { + return { + type: 'ask_user_question', + question, + }; +} + +/** Job-context fields wrapped around a {@link Agents.HumanInterruptPayload}. */ +export interface PendingActionContext { streamId: string; conversationId?: string; /** Stable per-turn identifier (e.g. responseMessageId or LangGraph checkpoint_ns). */ runId?: string; responseMessageId?: string; - /** One entry per tool call awaiting review in this interrupt. */ - toolCalls: Array<{ - name: string; - arguments: string | Record; - tool_call_id: string; - description?: string; - }>; - /** Override decisions per tool name. Falls back to {@link DEFAULT_REVIEW_DECISIONS}. */ - decisionsByToolName?: Record; /** Optional TTL (ms). When set, `expiresAt = createdAt + ttlMs`. */ ttlMs?: number; /** Override actionId; defaults to a fresh uuid. */ @@ -78,36 +127,25 @@ export interface BuildPendingActionInput { } /** - * Build a {@link Agents.PendingAction} record from one or more paused tool calls. + * Wrap a HumanInterruptPayload (from the SDK or synthesized locally) as a + * {@link Agents.PendingAction} record persisted with the job. * - * The resulting `payload` mirrors LangChain HITL middleware's `HumanInterrupt` shape - * (`action_requests` + `review_configs`) so it can be forwarded directly when the - * SDK adopts native HITL primitives. + * Accepts both interrupt categories (`tool_approval` and `ask_user_question`) + * via the discriminated union — the host doesn't need to branch. */ -export function buildPendingAction(input: BuildPendingActionInput): Agents.PendingAction { +export function buildPendingAction( + payload: Agents.HumanInterruptPayload, + ctx: PendingActionContext, +): Agents.PendingAction { const createdAt = Date.now(); - const action_requests: Agents.ToolApprovalRequest[] = input.toolCalls.map((tc) => ({ - name: tc.name, - arguments: tc.arguments, - tool_call_id: tc.tool_call_id, - description: tc.description, - })); - const review_configs: Agents.ToolReviewConfig[] = input.toolCalls.map((tc) => ({ - action_name: tc.name, - allowed_decisions: input.decisionsByToolName?.[tc.name] ?? DEFAULT_REVIEW_DECISIONS, - })); return { - actionId: input.actionId ?? randomUUID(), - streamId: input.streamId, - conversationId: input.conversationId, - runId: input.runId, - responseMessageId: input.responseMessageId, - payload: { - type: 'tool_approval', - action_requests, - review_configs, - }, + actionId: ctx.actionId ?? randomUUID(), + streamId: ctx.streamId, + conversationId: ctx.conversationId, + runId: ctx.runId, + responseMessageId: ctx.responseMessageId, + payload, createdAt, - expiresAt: input.ttlMs ? createdAt + input.ttlMs : undefined, + expiresAt: ctx.ttlMs ? createdAt + ctx.ttlMs : undefined, }; } diff --git a/packages/api/src/stream/__tests__/pendingAction.spec.ts b/packages/api/src/stream/__tests__/pendingAction.spec.ts index 11dc991ed9..e81137c39c 100644 --- a/packages/api/src/stream/__tests__/pendingAction.spec.ts +++ b/packages/api/src/stream/__tests__/pendingAction.spec.ts @@ -2,7 +2,7 @@ import type { Agents } from 'librechat-data-provider'; import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport'; import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore'; import { GenerationJobManagerClass } from '~/stream/GenerationJobManager'; -import { buildPendingAction } from '~/agents/hitl/policy'; +import { buildPendingAction, buildToolApprovalPayload } from '~/agents/hitl/policy'; jest.spyOn(console, 'log').mockImplementation(); @@ -25,12 +25,14 @@ describe('GenerationJobManager pending-action lifecycle (in-memory)', () => { }); function buildAction(streamId: string, overrides: Partial = {}) { - const action = buildPendingAction({ + const payload = buildToolApprovalPayload([ + { name: 'shell', arguments: { command: 'ls' }, tool_call_id: 'call_abc' }, + ]); + const action = buildPendingAction(payload, { streamId, conversationId: streamId, runId: 'run-1', responseMessageId: 'msg-1', - toolCalls: [{ name: 'shell', arguments: { command: 'ls' }, tool_call_id: 'call_abc' }], }); return { ...action, ...overrides }; } @@ -48,7 +50,10 @@ describe('GenerationJobManager pending-action lifecycle (in-memory)', () => { const pending = await manager.getPendingAction(streamId); expect(pending).not.toBeNull(); expect(pending?.actionId).toBe(action.actionId); - expect(pending?.payload.action_requests[0].name).toBe('shell'); + expect(pending?.payload.type).toBe('tool_approval'); + if (pending?.payload.type === 'tool_approval') { + expect(pending.payload.action_requests[0].name).toBe('shell'); + } }); test('getPendingAction returns null for jobs not in requires_action', async () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index b048e3ccfb..c1aaafc00c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -755,34 +755,45 @@ const remoteApiSchema = z.object({ }); /** - * Decision applied to a tool call before execution. - * Mirrors LangChain HITL middleware decision space. + * Permission mode applied to a tool call. Mirrors `@librechat/agents`'s + * `ToolPolicyMode` 1:1. * - * - `allow`: run the tool without prompting (default). - * - `deny`: block the tool; the agent receives a rejection message. - * - `ask`: pause the run and require user approval before execution. + * - `default`: ask the user about anything not explicitly allowed (default-on). + * - `dontAsk`: deny anything not explicitly allowed (headless / API-key flows). + * - `bypass`: auto-approve everything that isn't explicitly denied + * (the user-facing "stop asking me" toggle). + * + * Subagents inherit the parent's mode; this is enforced by the SDK and not + * overridable per-subagent. */ -export const toolApprovalDecisionSchema = z.enum(['allow', 'deny', 'ask']); -export type ToolApprovalDecision = z.infer; +export const toolApprovalModeSchema = z.enum(['default', 'dontAsk', 'bypass']); +export type ToolApprovalMode = z.infer; /** * Per-endpoint tool-approval policy. * - * Resolution order for a given tool call: - * 1. If the tool name is in `excluded`, the result is `allow`. - * 2. If the tool name is in `required`, the result is `ask`. - * 3. Otherwise, the result is `default` (which itself defaults to `allow`). + * Shape mirrors `@librechat/agents`'s `ToolPolicyConfig` so the host can map it + * directly into `createToolPolicyHook(config)`. The SDK does the evaluation + * (`deny → bypass → allow → ask → dontAsk → fallthrough(ask)`); this config + * just describes the surface. * - * Tool names are matched as exact strings against the registered tool name. - * Pattern matching (`mcp:*`, etc.) is intentionally deferred until we have - * a concrete need. + * Conventions: + * - All list entries are matched as globs (`*`). Use `mcp:server:*` to scope + * a rule to every tool from a single MCP server. + * - `deny` always wins, including under `bypass`. + * - `enabled: false` is a LibreChat-only kill switch that disables the entire + * HITL machinery for this endpoint (no checkpointer, no hooks, no prompts). + * This is admin-level; users toggle prompting via `mode: 'bypass'` instead. */ export const toolApprovalPolicySchema = z .object({ - /** Decision applied when no rule matches. Defaults to `'allow'` at policy resolution time. */ - default: toolApprovalDecisionSchema.optional(), - required: z.array(z.string()).optional(), - excluded: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + mode: toolApprovalModeSchema.optional(), + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + ask: z.array(z.string()).optional(), + /** Optional reason template surfaced in the prompt; `{tool}` is interpolated. */ + reason: z.string().optional(), }) .optional(); diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 51b6be6ed1..24f9894a83 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -91,7 +91,7 @@ export namespace Agents { */ approval?: { actionId: string; - allowed_decisions: ToolApprovalDecision[]; + allowed_decisions: ToolApprovalDecisionType[]; description?: string; }; }; @@ -280,26 +280,33 @@ export namespace Agents { /** Approval metadata, set when a tool call is paused for human review. */ approval?: { actionId: string; - allowed_decisions: ToolApprovalDecision[]; + allowed_decisions: ToolApprovalDecisionType[]; description?: string; }; }; export type AgentToolCall = FunctionToolCall | ToolCall; /** - * Human-in-the-loop interrupt category. Currently scoped to tool approvals. - * Reserved for future categories like `ask_user_question` (model-driven prompts) - * and `respond` (deferred user reply), matching LangChain HITL semantics. + * Human-in-the-loop interrupt categories. The discriminator on + * {@link HumanInterruptPayload}. + * + * - `tool_approval`: agent paused before executing one or more tools; user + * approves / rejects / edits each call. + * - `ask_user_question`: agent invoked the `AskUserQuestion` tool to gather + * clarification; user replies with free-form text (or selects an option). + * + * `tool_approval` is a permission gate; `ask_user_question` is a clarification + * channel — they share the {@link PendingAction} envelope but have different + * UI affordances and resume payloads. */ - export type HumanInterruptType = 'tool_approval'; + export type HumanInterruptType = 'tool_approval' | 'ask_user_question'; - /** Decisions a user can make for a paused tool call. Mirrors LangChain HITL middleware. */ - export type ToolApprovalDecision = 'approve' | 'reject' | 'edit' | 'respond'; + /** String enum of decision kinds the user can make on a paused tool call. */ + export type ToolApprovalDecisionType = 'approve' | 'reject' | 'edit' | 'respond'; /** * One pending tool execution awaiting user review. - * Field naming mirrors LangChain's `ActionRequest` shape so the same payload - * can be forwarded directly when the SDK adopts native HITL primitives. + * Field naming mirrors LangChain HumanInterrupt's `ActionRequest`. */ export interface ToolApprovalRequest { /** Tool name as registered with the agent */ @@ -315,19 +322,42 @@ export namespace Agents { /** Per-tool review configuration: which decisions the user is allowed to make. */ export interface ToolReviewConfig { action_name: string; - allowed_decisions: ToolApprovalDecision[]; + allowed_decisions: ToolApprovalDecisionType[]; } - /** - * Full interrupt payload emitted when an agent run pauses for human review. - * Mirrors LangChain's `HumanInterrupt` shape (`action_requests` + `review_configs`). - */ - export interface HumanInterruptPayload { - type: HumanInterruptType; + /** Interrupt payload for a tool-approval pause. */ + export interface ToolApprovalInterruptPayload { + type: 'tool_approval'; action_requests: ToolApprovalRequest[]; review_configs: ToolReviewConfig[]; } + /** A selectable answer for an ask-user-question prompt. */ + export interface AskUserQuestionOption { + label: string; + value: string; + } + + /** The question itself: free-form prompt with optional curated answers. */ + export interface AskUserQuestionRequest { + question: string; + options?: AskUserQuestionOption[]; + } + + /** Interrupt payload for an ask-user-question pause. */ + export interface AskUserQuestionInterruptPayload { + type: 'ask_user_question'; + question: AskUserQuestionRequest; + } + + /** + * Discriminated by `type`. Mirrors `@librechat/agents`'s `HumanInterruptPayload` + * so the SDK's `Run.getInterrupt()` output can be embedded directly. + */ + export type HumanInterruptPayload = + | ToolApprovalInterruptPayload + | AskUserQuestionInterruptPayload; + /** * Server-side record of a job that is waiting for user input. * Persisted with the job; consumed by approval routes and the status endpoint. @@ -346,16 +376,36 @@ export namespace Agents { expiresAt?: number; } + /** + * Scope of a tool-approval decision — drives the "remember this" persistence + * envelope. Storage of session/always decisions is a Slice B+ concern; the + * field is on the wire today so route signatures don't break later. + */ + export type DecisionScope = 'once' | 'session' | 'always'; + /** * Per-tool decision returned from the approval UI. - * `editedArguments` is required when `decision === 'edit'`. - * `responseText` is required when `decision === 'respond'`. + * Wire format. The host adapts each entry to the SDK's discriminated + * `ToolApprovalDecision` (e.g. `{ type: 'edit', updatedInput }`) at the resume route. + * + * Constraints: + * - `editedArguments` is required when `decision === 'edit'`. + * - `responseText` is required when `decision === 'respond'`. + * - `reason` is optional metadata; useful for reject/edit audit trails. + * - `scope` defaults to `'once'`. */ export interface ToolApprovalResolution { tool_call_id: string; - decision: ToolApprovalDecision; + decision: ToolApprovalDecisionType; editedArguments?: Record; responseText?: string; + reason?: string; + scope?: DecisionScope; + } + + /** Wire format for an ask-user-question response. */ + export interface AskUserQuestionResolution { + answer: string; } export interface ExtendedMessageContent {