mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪝 feat: HITL Tool Approval Scaffolding
Adds the foundational types, job-state, config schema, and policy module for human-in-the-loop tool approval. Purely additive — no behavior change on existing runs. Lands ahead of the agents-SDK interrupt/checkpointer integration so both tracks can land independently. - LangChain HumanInterrupt-shaped types in `Agents.*` namespace (`HumanInterruptPayload`, `ToolApprovalRequest`, `ToolReviewConfig`, `PendingAction`, `ToolApprovalResolution`); `ToolCall`/`ToolCallDelta` gain an optional `approval` field. - New `requires_action` job status (non-terminal) plus `pendingAction` field on `SerializableJobData` and `GenerationJobMetadata`. Both stores treat the status as paused-but-alive; Redis `updateJob` has explicit `requires_action`/`running` transition branches that refresh the hash TTL, manage the `runningJobs` set, and `HDEL pendingAction` on resume. Both stores include `requires_action` in `getActiveJobIdsByUser`. - `GenerationJobManager` gains `markRequiresAction`, `getPendingAction`, `clearPendingAction`; `getJobCountByStatus` aggregates the new status. - `endpoints.agents.toolApproval` config (`default`/`required`/`excluded`) and a policy module exporting `decideToolApproval`, `requiresApproval`, and `buildPendingAction` (the LangChain-shaped payload builder). - 20 unit tests covering policy resolution and the manager lifecycle.
This commit is contained in:
parent
a7f16911b2
commit
203cacc31d
12 changed files with 580 additions and 9 deletions
1
packages/api/src/agents/hitl/index.ts
Normal file
1
packages/api/src/agents/hitl/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './policy';
|
||||
125
packages/api/src/agents/hitl/policy.spec.ts
Normal file
125
packages/api/src/agents/hitl/policy.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { TToolApprovalPolicy } from 'librechat-data-provider';
|
||||
import { decideToolApproval, requiresApproval, buildPendingAction } from './policy';
|
||||
|
||||
describe('decideToolApproval', () => {
|
||||
test('returns "allow" when no policy is configured', () => {
|
||||
expect(decideToolApproval(undefined, { name: 'shell' })).toBe('allow');
|
||||
});
|
||||
|
||||
test('returns the configured default when no rule matches', () => {
|
||||
const policy: TToolApprovalPolicy = { default: 'ask' };
|
||||
expect(decideToolApproval(policy, { name: 'unmapped' })).toBe('ask');
|
||||
});
|
||||
|
||||
test('falls back to "allow" when default is omitted', () => {
|
||||
const policy: TToolApprovalPolicy = { required: ['shell'] };
|
||||
expect(decideToolApproval(policy, { name: 'web_search' })).toBe('allow');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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('buildPendingAction', () => {
|
||||
const baseInput = {
|
||||
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'] },
|
||||
]);
|
||||
});
|
||||
|
||||
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('generates a uuid actionId by default', () => {
|
||||
const a = buildPendingAction(baseInput);
|
||||
const b = buildPendingAction(baseInput);
|
||||
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' });
|
||||
expect(action.actionId).toBe('fixed-id');
|
||||
});
|
||||
|
||||
test('sets expiresAt only when ttlMs is provided', () => {
|
||||
const without = buildPendingAction(baseInput);
|
||||
expect(without.expiresAt).toBeUndefined();
|
||||
|
||||
const ttl = 5_000;
|
||||
const before = Date.now();
|
||||
const withTtl = buildPendingAction({ ...baseInput, 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');
|
||||
});
|
||||
});
|
||||
113
packages/api/src/agents/hitl/policy.ts
Normal file
113
packages/api/src/agents/hitl/policy.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import type { Agents, TToolApprovalPolicy, ToolApprovalDecision } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Default decision set 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.
|
||||
*/
|
||||
const DEFAULT_REVIEW_DECISIONS: Agents.ToolApprovalDecision[] = ['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.
|
||||
*/
|
||||
export interface ToolRef {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a tool call requires human approval, denial, or can run as-is.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function decideToolApproval(
|
||||
policy: TToolApprovalPolicy | undefined,
|
||||
tool: ToolRef,
|
||||
): ToolApprovalDecision {
|
||||
if (!policy) {
|
||||
return 'allow';
|
||||
}
|
||||
const name = tool.name;
|
||||
const fallback = policy.default ?? 'allow';
|
||||
if (!name) {
|
||||
return fallback;
|
||||
}
|
||||
if (policy.excluded?.includes(name)) {
|
||||
return 'allow';
|
||||
}
|
||||
if (policy.required?.includes(name)) {
|
||||
return 'ask';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 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';
|
||||
}
|
||||
|
||||
/** Input shape for {@link buildPendingAction}. */
|
||||
export interface BuildPendingActionInput {
|
||||
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<string, unknown>;
|
||||
tool_call_id: string;
|
||||
description?: string;
|
||||
}>;
|
||||
/** Override decisions per tool name. Falls back to {@link DEFAULT_REVIEW_DECISIONS}. */
|
||||
decisionsByToolName?: Record<string, Agents.ToolApprovalDecision[]>;
|
||||
/** Optional TTL (ms). When set, `expiresAt = createdAt + ttlMs`. */
|
||||
ttlMs?: number;
|
||||
/** Override actionId; defaults to a fresh uuid. */
|
||||
actionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link Agents.PendingAction} record from one or more paused tool calls.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function buildPendingAction(input: BuildPendingActionInput): 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,
|
||||
},
|
||||
createdAt,
|
||||
expiresAt: input.ttlMs ? createdAt + input.ttlMs : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -27,3 +27,4 @@ export * from './tools';
|
|||
export * from './validation';
|
||||
export * from './added';
|
||||
export * from './load';
|
||||
export * from './hitl';
|
||||
|
|
|
|||
|
|
@ -1313,6 +1313,54 @@ class GenerationJobManagerClass {
|
|||
this.jobStore.setGraph(streamId, graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a job to `requires_action` and persist the pending review record.
|
||||
*
|
||||
* The job is NOT cleaned up: chunks, run steps, and user-active-set membership
|
||||
* remain so the resume path can rebuild context. The Redis job-hash TTL is
|
||||
* refreshed by the store to give the user the full TTL window to respond.
|
||||
*
|
||||
* @param streamId - The stream identifier
|
||||
* @param pendingAction - The pending review record (tool approval, etc.)
|
||||
*/
|
||||
async markRequiresAction(streamId: string, pendingAction: Agents.PendingAction): Promise<void> {
|
||||
await this.jobStore.updateJob(streamId, {
|
||||
status: 'requires_action',
|
||||
pendingAction,
|
||||
});
|
||||
logger.debug(
|
||||
`[GenerationJobManager] Job awaiting human review: ${streamId} action=${pendingAction.actionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pending review record for a job.
|
||||
*
|
||||
* Returns null when the job doesn't exist, isn't in `requires_action`,
|
||||
* or has no recorded pending action. Callers (status endpoint, approval routes)
|
||||
* should treat null as "nothing to approve."
|
||||
*/
|
||||
async getPendingAction(streamId: string): Promise<Agents.PendingAction | null> {
|
||||
const jobData = await this.jobStore.getJob(streamId);
|
||||
if (!jobData || jobData.status !== 'requires_action') {
|
||||
return null;
|
||||
}
|
||||
return jobData.pendingAction ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending review record and return the job to `running`.
|
||||
* Called by the resume path after a user approval/rejection has been accepted
|
||||
* and the run is about to be re-driven.
|
||||
*/
|
||||
async clearPendingAction(streamId: string): Promise<void> {
|
||||
await this.jobStore.updateJob(streamId, {
|
||||
status: 'running',
|
||||
pendingAction: undefined,
|
||||
});
|
||||
logger.debug(`[GenerationJobManager] Cleared pending action: ${streamId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume state for reconnecting clients.
|
||||
*/
|
||||
|
|
@ -1538,13 +1586,14 @@ class GenerationJobManagerClass {
|
|||
* Get job count by status.
|
||||
*/
|
||||
async getJobCountByStatus(): Promise<Record<t.GenerationJobStatus, number>> {
|
||||
const [running, complete, error, aborted] = await Promise.all([
|
||||
const [running, complete, error, aborted, requires_action] = await Promise.all([
|
||||
this.jobStore.getJobCountByStatus('running'),
|
||||
this.jobStore.getJobCountByStatus('complete'),
|
||||
this.jobStore.getJobCountByStatus('error'),
|
||||
this.jobStore.getJobCountByStatus('aborted'),
|
||||
this.jobStore.getJobCountByStatus('requires_action'),
|
||||
]);
|
||||
return { running, complete, error, aborted };
|
||||
return { running, complete, error, aborted, requires_action };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
107
packages/api/src/stream/__tests__/pendingAction.spec.ts
Normal file
107
packages/api/src/stream/__tests__/pendingAction.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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';
|
||||
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
describe('GenerationJobManager pending-action lifecycle (in-memory)', () => {
|
||||
let manager: GenerationJobManagerClass;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new GenerationJobManagerClass();
|
||||
manager.configure({
|
||||
jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }),
|
||||
eventTransport: new InMemoryEventTransport(),
|
||||
isRedis: false,
|
||||
cleanupOnComplete: false,
|
||||
});
|
||||
manager.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.destroy();
|
||||
});
|
||||
|
||||
function buildAction(streamId: string, overrides: Partial<Agents.PendingAction> = {}) {
|
||||
const action = buildPendingAction({
|
||||
streamId,
|
||||
conversationId: streamId,
|
||||
runId: 'run-1',
|
||||
responseMessageId: 'msg-1',
|
||||
toolCalls: [{ name: 'shell', arguments: { command: 'ls' }, tool_call_id: 'call_abc' }],
|
||||
});
|
||||
return { ...action, ...overrides };
|
||||
}
|
||||
|
||||
test('markRequiresAction persists the pending action and transitions status', async () => {
|
||||
const streamId = 'stream-mark';
|
||||
await manager.createJob(streamId, 'user-1');
|
||||
|
||||
const action = buildAction(streamId);
|
||||
await manager.markRequiresAction(streamId, action);
|
||||
|
||||
const status = await manager.getJobStatus(streamId);
|
||||
expect(status).toBe('requires_action');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('getPendingAction returns null for jobs not in requires_action', async () => {
|
||||
const streamId = 'stream-running';
|
||||
await manager.createJob(streamId, 'user-1');
|
||||
expect(await manager.getPendingAction(streamId)).toBeNull();
|
||||
});
|
||||
|
||||
test('getPendingAction returns null when the job does not exist', async () => {
|
||||
expect(await manager.getPendingAction('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
test('clearPendingAction returns the job to running and removes the pending record', async () => {
|
||||
const streamId = 'stream-clear';
|
||||
await manager.createJob(streamId, 'user-1');
|
||||
|
||||
await manager.markRequiresAction(streamId, buildAction(streamId));
|
||||
expect(await manager.getJobStatus(streamId)).toBe('requires_action');
|
||||
|
||||
await manager.clearPendingAction(streamId);
|
||||
|
||||
expect(await manager.getJobStatus(streamId)).toBe('running');
|
||||
expect(await manager.getPendingAction(streamId)).toBeNull();
|
||||
});
|
||||
|
||||
test('requires_action drops the running count but keeps the user-active set', async () => {
|
||||
const streamId = 'stream-counts';
|
||||
await manager.createJob(streamId, 'user-counts');
|
||||
|
||||
const beforeCounts = await manager.getJobCountByStatus();
|
||||
expect(beforeCounts.running).toBe(1);
|
||||
expect(beforeCounts.requires_action).toBe(0);
|
||||
|
||||
await manager.markRequiresAction(streamId, buildAction(streamId));
|
||||
|
||||
const afterCounts = await manager.getJobCountByStatus();
|
||||
expect(afterCounts.running).toBe(0);
|
||||
expect(afterCounts.requires_action).toBe(1);
|
||||
|
||||
// Pending-approval jobs still occupy the user's conversation slot.
|
||||
const active = await manager.getActiveJobIdsForUser('user-counts');
|
||||
expect(active).toContain(streamId);
|
||||
});
|
||||
|
||||
test('getActiveJobIdsForUser excludes terminal jobs but includes requires_action', async () => {
|
||||
await manager.createJob('s-running', 'user-mix');
|
||||
await manager.createJob('s-paused', 'user-mix');
|
||||
await manager.createJob('s-done', 'user-mix');
|
||||
|
||||
await manager.markRequiresAction('s-paused', buildAction('s-paused'));
|
||||
await manager.completeJob('s-done');
|
||||
|
||||
const active = await manager.getActiveJobIdsForUser('user-mix');
|
||||
expect(active.sort()).toEqual(['s-paused', 's-running']);
|
||||
});
|
||||
});
|
||||
|
|
@ -295,8 +295,9 @@ export class InMemoryJobStore implements IJobStore {
|
|||
|
||||
for (const streamId of trackedIds) {
|
||||
const job = this.jobs.get(streamId);
|
||||
// Only include if job exists AND is still running
|
||||
if (job && job.status === 'running') {
|
||||
// Include running jobs and jobs paused for human review (e.g. tool approval).
|
||||
// A pending-approval job still occupies the user's conversation slot.
|
||||
if (job && (job.status === 'running' || job.status === 'requires_action')) {
|
||||
activeIds.push(streamId);
|
||||
} else {
|
||||
// Self-healing: job completed/deleted but mapping wasn't cleaned - fix it now
|
||||
|
|
|
|||
|
|
@ -215,6 +215,41 @@ export class RedisJobStore implements IJobStore {
|
|||
return;
|
||||
}
|
||||
|
||||
if (updates.status === 'requires_action') {
|
||||
// Job paused for human review — non-terminal.
|
||||
// Remove from runningJobs so getJobCountByStatus('running') stays accurate,
|
||||
// refresh the hash TTL so the user has the full window to respond, and
|
||||
// leave chunks/runSteps/user-active-set untouched so resume can rebuild state.
|
||||
if (this.isCluster) {
|
||||
await this.redis.srem(KEYS.runningJobs, streamId);
|
||||
await this.redis.expire(key, this.ttl.running);
|
||||
} else {
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.srem(KEYS.runningJobs, streamId);
|
||||
pipeline.expire(key, this.ttl.running);
|
||||
await pipeline.exec();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.status === 'running') {
|
||||
// Resume from requires_action — re-add to runningJobs (idempotent), refresh TTL,
|
||||
// and explicitly clear any stale pendingAction (serializeJob skips `undefined`,
|
||||
// so the only way to remove a hash field is HDEL).
|
||||
if (this.isCluster) {
|
||||
await this.redis.sadd(KEYS.runningJobs, streamId);
|
||||
await this.redis.expire(key, this.ttl.running);
|
||||
await this.redis.hdel(key, 'pendingAction');
|
||||
} else {
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.sadd(KEYS.runningJobs, streamId);
|
||||
pipeline.expire(key, this.ttl.running);
|
||||
pipeline.hdel(key, 'pendingAction');
|
||||
await pipeline.exec();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.status && ['complete', 'error', 'aborted'].includes(updates.status)) {
|
||||
// Proactively remove from user's job set (requires reading userId from the job hash)
|
||||
const job = await this.getJob(streamId);
|
||||
|
|
@ -428,8 +463,9 @@ export class RedisJobStore implements IJobStore {
|
|||
|
||||
for (const streamId of trackedIds) {
|
||||
const job = await this.getJob(streamId);
|
||||
// Only include if job exists AND is still running
|
||||
if (job && job.status === 'running') {
|
||||
// Include running jobs and jobs paused for human review (e.g. tool approval).
|
||||
// A pending-approval job still occupies the user's conversation slot.
|
||||
if (job && (job.status === 'running' || job.status === 'requires_action')) {
|
||||
activeIds.push(streamId);
|
||||
} else {
|
||||
// Self-healing: job completed/deleted but mapping wasn't cleaned - mark for removal
|
||||
|
|
@ -923,6 +959,7 @@ export class RedisJobStore implements IJobStore {
|
|||
promptTokens: data.promptTokens ? parseInt(data.promptTokens, 10) : undefined,
|
||||
titleEvent: data.titleEvent || undefined,
|
||||
replayEvents: data.replayEvents || undefined,
|
||||
pendingAction: data.pendingAction ? JSON.parse(data.pendingAction) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import type { StandardGraph } from '@librechat/agents';
|
|||
import type { Agents } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Job status enum
|
||||
* Job status enum.
|
||||
*
|
||||
* `requires_action` is non-terminal: the run has paused for human review
|
||||
* (e.g. tool approval) and is expected to be resumed by an approval route.
|
||||
* Stores must NOT cleanup `requires_action` jobs as if they were complete.
|
||||
*/
|
||||
export type JobStatus = 'running' | 'complete' | 'error' | 'aborted';
|
||||
export type JobStatus = 'running' | 'complete' | 'error' | 'aborted' | 'requires_action';
|
||||
|
||||
/**
|
||||
* Serializable job data - no object references, suitable for Redis/external storage
|
||||
|
|
@ -53,6 +57,12 @@ export interface SerializableJobData {
|
|||
iconURL?: string;
|
||||
model?: string;
|
||||
promptTokens?: number;
|
||||
|
||||
/**
|
||||
* Set when status is `requires_action`. Describes the human review the
|
||||
* run is waiting on. Cleared by the resume path before the job returns to `running`.
|
||||
*/
|
||||
pendingAction?: Agents.PendingAction;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export interface GenerationJobMetadata {
|
|||
model?: string;
|
||||
/** Prompt token count for abort token spending */
|
||||
promptTokens?: number;
|
||||
/** Set when the job is paused for human review (status === 'requires_action') */
|
||||
pendingAction?: Agents.PendingAction;
|
||||
}
|
||||
|
||||
export type GenerationJobStatus = 'running' | 'complete' | 'error' | 'aborted';
|
||||
export type GenerationJobStatus = 'running' | 'complete' | 'error' | 'aborted' | 'requires_action';
|
||||
|
||||
export interface GenerationJob {
|
||||
streamId: string;
|
||||
|
|
|
|||
|
|
@ -557,6 +557,40 @@ const remoteApiSchema = z.object({
|
|||
auth: remoteApiAuthSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Decision applied to a tool call before execution.
|
||||
* Mirrors LangChain HITL middleware decision space.
|
||||
*
|
||||
* - `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.
|
||||
*/
|
||||
export const toolApprovalDecisionSchema = z.enum(['allow', 'deny', 'ask']);
|
||||
export type ToolApprovalDecision = z.infer<typeof toolApprovalDecisionSchema>;
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TToolApprovalPolicy = z.infer<typeof toolApprovalPolicySchema>;
|
||||
|
||||
export const agentsEndpointSchema = baseEndpointSchema
|
||||
.omit({ baseURL: true })
|
||||
.merge(
|
||||
|
|
@ -579,6 +613,8 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
})
|
||||
.optional(),
|
||||
remoteApi: remoteApiSchema.optional(),
|
||||
/** Human-in-the-loop tool approval policy. Off by default. */
|
||||
toolApproval: toolApprovalPolicySchema,
|
||||
}),
|
||||
)
|
||||
.default({
|
||||
|
|
|
|||
|
|
@ -83,6 +83,16 @@ export namespace Agents {
|
|||
auth?: string;
|
||||
/** Expiration time */
|
||||
expires_at?: number;
|
||||
/**
|
||||
* When set, this tool call is paused for human review.
|
||||
* The presence of this field signals the UI to render approval controls
|
||||
* instead of the in-flight tool execution state.
|
||||
*/
|
||||
approval?: {
|
||||
actionId: string;
|
||||
allowed_decisions: ToolApprovalDecision[];
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolEndEvent = {
|
||||
|
|
@ -262,8 +272,87 @@ export namespace Agents {
|
|||
tool_calls?: ToolCallChunk[];
|
||||
auth?: string;
|
||||
expires_at?: number;
|
||||
/** Approval metadata, set when a tool call is paused for human review. */
|
||||
approval?: {
|
||||
actionId: string;
|
||||
allowed_decisions: ToolApprovalDecision[];
|
||||
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.
|
||||
*/
|
||||
export type HumanInterruptType = 'tool_approval';
|
||||
|
||||
/** Decisions a user can make for a paused tool call. Mirrors LangChain HITL middleware. */
|
||||
export type ToolApprovalDecision = '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.
|
||||
*/
|
||||
export interface ToolApprovalRequest {
|
||||
/** Tool name as registered with the agent */
|
||||
name: string;
|
||||
/** Sanitized arguments (no auth tokens / file blobs). May be string or parsed object. */
|
||||
arguments: string | Record<string, unknown>;
|
||||
/** Provider tool_call_id linking this request to the model's tool_use block */
|
||||
tool_call_id: string;
|
||||
/** Optional human-readable description shown alongside the prompt */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Per-tool review configuration: which decisions the user is allowed to make. */
|
||||
export interface ToolReviewConfig {
|
||||
action_name: string;
|
||||
allowed_decisions: ToolApprovalDecision[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
action_requests: ToolApprovalRequest[];
|
||||
review_configs: ToolReviewConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side record of a job that is waiting for user input.
|
||||
* Persisted with the job; consumed by approval routes and the status endpoint.
|
||||
*/
|
||||
export interface PendingAction {
|
||||
/** Stable identifier used in approval URLs */
|
||||
actionId: string;
|
||||
streamId: string;
|
||||
conversationId?: string;
|
||||
/** Stable per-turn identifier (LangGraph checkpoint_ns) when available */
|
||||
runId?: string;
|
||||
responseMessageId?: string;
|
||||
payload: HumanInterruptPayload;
|
||||
createdAt: number;
|
||||
/** Optional expiry; clients should treat past `expiresAt` as stale */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tool decision returned from the approval UI.
|
||||
* `editedArguments` is required when `decision === 'edit'`.
|
||||
* `responseText` is required when `decision === 'respond'`.
|
||||
*/
|
||||
export interface ToolApprovalResolution {
|
||||
tool_call_id: string;
|
||||
decision: ToolApprovalDecision;
|
||||
editedArguments?: Record<string, unknown>;
|
||||
responseText?: string;
|
||||
}
|
||||
|
||||
export interface ExtendedMessageContent {
|
||||
type?: string;
|
||||
text?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue