diff --git a/packages/api/src/agents/hitl/index.ts b/packages/api/src/agents/hitl/index.ts new file mode 100644 index 0000000000..62d5b849e4 --- /dev/null +++ b/packages/api/src/agents/hitl/index.ts @@ -0,0 +1 @@ +export * from './policy'; diff --git a/packages/api/src/agents/hitl/policy.spec.ts b/packages/api/src/agents/hitl/policy.spec.ts new file mode 100644 index 0000000000..2387d77f0f --- /dev/null +++ b/packages/api/src/agents/hitl/policy.spec.ts @@ -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'); + }); +}); diff --git a/packages/api/src/agents/hitl/policy.ts b/packages/api/src/agents/hitl/policy.ts new file mode 100644 index 0000000000..9f6516ce39 --- /dev/null +++ b/packages/api/src/agents/hitl/policy.ts @@ -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; + 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. */ + 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, + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 14e0f480fd..fc14367e3e 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -27,3 +27,4 @@ export * from './tools'; export * from './validation'; export * from './added'; export * from './load'; +export * from './hitl'; diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 238db4e6cb..3ae476b245 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -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 { + 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 { + 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 { + 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> { - 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 }; } /** diff --git a/packages/api/src/stream/__tests__/pendingAction.spec.ts b/packages/api/src/stream/__tests__/pendingAction.spec.ts new file mode 100644 index 0000000000..11dc991ed9 --- /dev/null +++ b/packages/api/src/stream/__tests__/pendingAction.spec.ts @@ -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 = {}) { + 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']); + }); +}); diff --git a/packages/api/src/stream/implementations/InMemoryJobStore.ts b/packages/api/src/stream/implementations/InMemoryJobStore.ts index f318f3baf5..4567b55ae6 100644 --- a/packages/api/src/stream/implementations/InMemoryJobStore.ts +++ b/packages/api/src/stream/implementations/InMemoryJobStore.ts @@ -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 diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index d49affdc28..17f12a20da 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -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, }; } } diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index 3e6786a49b..61fb7a5795 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -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; } /** diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index dd125a1aab..c93d99d506 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -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; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 7426d20631..d4c1d7818d 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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; + +/** + * 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; + 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({ diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 0eab753bd3..7b2c33a7ac 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -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; + /** 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; + responseText?: string; + } + export interface ExtendedMessageContent { type?: string; text?: string;