🪝 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:
Danny Avila 2026-05-04 03:57:10 +09:00
parent a7f16911b2
commit 203cacc31d
12 changed files with 580 additions and 9 deletions

View file

@ -0,0 +1 @@
export * from './policy';

View 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');
});
});

View 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,
};
}

View file

@ -27,3 +27,4 @@ export * from './tools';
export * from './validation';
export * from './added';
export * from './load';
export * from './hitl';

View file

@ -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 };
}
/**

View 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']);
});
});

View file

@ -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

View file

@ -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,
};
}
}

View file

@ -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;
}
/**

View file

@ -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;

View file

@ -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({

View file

@ -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;