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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,7 @@ async function readWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1147,6 +1148,7 @@ async function searchWorkspace({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1200,6 +1202,7 @@ async function listWorkspaceFiles({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1239,6 +1242,7 @@ async function writeWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1278,6 +1282,7 @@ async function editWorkspaceFile({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down Expand Up @@ -1316,6 +1321,7 @@ async function previewWorkspaceEdit({
return executeWorkspaceTool({
baseURL: codeApiBaseUrl,
maxQueueWaitMs,
codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
deadlineAtMs,
/** Minted per admission attempt: a queued call outlives one token TTL. */
Expand Down
26 changes: 26 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,28 @@ describe('Code Process', () => {
});

describe('readWorkspaceFile', () => {
it('forwards the configured rate-limit budget to the workspace transport', async () => {
const req = {
...mockReq,
config: { ...mockReq.config, endpoints: { agents: { codeApiMaxRetryWaitMs: 0 } } },
};
mockExecuteWorkspaceTool.mockResolvedValueOnce({ operation: 'read_file' });

await readWorkspaceFile({
file_path: 'src/app.ts',
workspace_id: 'primary',
start_line: 1,
max_lines: 10,
codeApiBaseUrl: 'https://attached-code.example.com/v1',
executionProfile: 'stateful',
req,
});

expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith(
expect.objectContaining({ codeApiMaxRetryWaitMs: 0 }),
);
});

it('forwards authenticated reads to the selected attached worker', async () => {
const controller = new AbortController();
const result = {
Expand Down Expand Up @@ -2083,6 +2105,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: 125_000,
deadlineAtMs: 160_000,
request: {
Expand Down Expand Up @@ -2144,6 +2167,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down Expand Up @@ -2204,6 +2228,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down Expand Up @@ -2266,6 +2291,7 @@ describe('Code Process', () => {
baseURL: 'https://attached-code.example.com/v1',
authHeaders: expect.any(Function),
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: undefined,
maxRequestTimeoutMs: undefined,
deadlineAtMs: undefined,
request: {
Expand Down
3 changes: 3 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,7 @@ async function loadToolDefinitionsWrapper({
enabled: codeExecutionEnabled,
context: resolvedCodeExecutionContext,
principalId: JSON.stringify([getTenantId(), req.user.id]),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
}),
};
Expand Down Expand Up @@ -1781,6 +1782,7 @@ async function loadAgentTools({
enabled: codeExecutionEnabled,
context: codeExecutionContext,
principalId: JSON.stringify([getTenantId(), req.user.id]),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId),
});
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
Expand Down Expand Up @@ -2315,6 +2317,7 @@ async function loadToolsForExecution({
maxQueueWaitMs: resolveAttachedWorkspaceQueueWaitMs(
codeExecutionContext.codeEnvironmentConfigSchema,
),
codeApiMaxRetryWaitMs: req.config?.endpoints?.agents?.codeApiMaxRetryWaitMs,
maxRequestTimeoutMs: resolveAttachedWorkspaceRequestTimeoutMs(
codeExecutionContext.codeEnvironmentConfigSchema,
),
Expand Down
39 changes: 39 additions & 0 deletions api/server/services/__tests__/ToolService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,43 @@ describe('ToolService - Action Capability Gating', () => {
});
});

it.each([true, false])(
'passes the Code API retry limit to repository instructions (definitionsOnly=%s)',
async (definitionsOnly) => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.execute_code,
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].codeApiMaxRetryWaitMs = 0;
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockResolveCodeExecutionContext.mockReturnValueOnce({
baseUrl: 'https://attached-code.example.com/v1',
codeSessionKey: 'attached-session',
executionProfile: 'stateful',
statefulSessions: true,
environmentType: 'attached',
environmentId: 'personal-machine',
});

const result = await loadAgentTools({
req,
res: {},
agent: {
id: 'attached-agent',
tools: [Tools.execute_code],
stateful_code_sessions: true,
},
definitionsOnly,
});

expect(result.repositoryInstructionSource).toEqual(
expect.objectContaining({ codeApiMaxRetryWaitMs: 0 }),
);
},
);

describe('isActionTool — cross-delimiter collision guard', () => {
it('should identify real action tools', () => {
expect(isActionTool(`get_weather${actionDelimiter}api_example_com`)).toBe(true);
Expand Down Expand Up @@ -2930,6 +2967,7 @@ describe('ToolService - Action Capability Gating', () => {
AgentCapabilities.stateful_code_sessions,
];
const req = createMockReq(capabilities);
req.config.endpoints[EModelEndpoint.agents].codeApiMaxRetryWaitMs = 0;
req.body = {
codeWorkspaces: [{ environmentId: 'personal-machine', workspaceId: 'project-a' }],
};
Expand Down Expand Up @@ -2970,6 +3008,7 @@ describe('ToolService - Action Capability Gating', () => {
gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' },
maxTimeoutMs: 120000,
maxQueueWaitMs: 0,
codeApiMaxRetryWaitMs: 0,
});
expect(mockResolveCodeExecutionWorkspaceContext).toHaveBeenCalledWith(
expect.objectContaining({ requestedSelections: req.body.codeWorkspaces }),
Expand Down
12 changes: 3 additions & 9 deletions packages/api/src/agents/handlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5843,11 +5843,11 @@ describe('createToolExecuteHandler', () => {
it.each([
{ budget: 1200, elapsed: 0, remaining: 1200 },
{ budget: 1200, elapsed: 400, remaining: 800 },
{ budget: 1200, elapsed: 1200, remaining: null },
{ budget: 1200, elapsed: 1500, remaining: null },
{ budget: 1200, elapsed: 1200, remaining: 0 },
{ budget: 1200, elapsed: 1500, remaining: 0 },
{ budget: 0, elapsed: 400, remaining: 0 },
])(
'shares a protected edit retry horizon ($budget ms, preview $elapsed ms)',
'shares a protected edit capacity retry horizon ($budget ms, preview $elapsed ms)',
async ({ budget, elapsed, remaining }) => {
const startedAt = Date.now();
let nowMs = startedAt;
Expand Down Expand Up @@ -5931,12 +5931,6 @@ describe('createToolExecuteHandler', () => {
deadlineAtMs: startedAt + 125_000,
}),
);
if (remaining == null) {
expect(result.status).toBe('error');
expect(result.errorMessage).toContain('The file was not modified');
expect(editWorkspaceFile).not.toHaveBeenCalled();
return;
}
expect(result.status).toBe('success');
expect(editWorkspaceFile).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
13 changes: 3 additions & 10 deletions packages/api/src/agents/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4111,17 +4111,10 @@ async function handleAttachedWorkspaceEditFileCall({
const filteredContent = filteredFileResult(tc, req, path.filePath, preview.content);
if (filteredContent != null) return filteredContent;
expectedBaseSha256 = preview.baseSha256;
/** Zero disables retries, not the two operations required for a protected
* edit. Positive horizons must not restart after a successful preview. */
/** A spent queue horizon disables capacity retries, not the required
* hash-guarded edit attempt or its independent rate-limit recovery. */
if (workspaceParams.maxQueueWaitMs > 0) {
const remainingMs = queueDeadlineAt - Date.now();
if (remainingMs <= 0) {
return errorResult(
tc,
'The workspace retry budget expired after preview. The file was not modified.',
);
}
workspaceParams.maxQueueWaitMs = remainingMs;
workspaceParams.maxQueueWaitMs = Math.max(0, queueDeadlineAt - Date.now());
}
}
const result = await options.editWorkspaceFile({
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/code/command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,24 @@ function commandResponse(overrides: Record<string, unknown> = {}): Response {
}

describe('createAttachedWorkspaceBashTool', () => {
test('honors a zero Code API rate-limit retry budget', async () => {
const fetchImpl: CodeBridgeFetch = jest.fn(
async () => new Response(JSON.stringify({ error: 'rate_limited' }), { status: 429 }),
);
const bashTool = createAttachedWorkspaceBashTool({
baseUrl: 'https://code.example.com/v1',
authHeaders: () => ({}),
workspaceId: 'project-a',
codeApiMaxRetryWaitMs: 0,
fetchImpl,
});

await expect(bashTool.invoke({ command: 'pwd' })).rejects.toThrow(
'The operation was not started',
);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

test('dispatches commands to the resolved conversation workspace instance', async () => {
const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse());
const workspaceInstanceId = 'e'.repeat(64);
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/code/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export function createAttachedWorkspaceBashTool({
gitIdentity,
maxTimeoutMs = WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS,
maxQueueWaitMs,
codeApiMaxRetryWaitMs,
maxRequestTimeoutMs,
fetchImpl,
}: {
Expand All @@ -315,6 +316,7 @@ export function createAttachedWorkspaceBashTool({
maxTimeoutMs?: number;
/** Retry horizon across typed queue expirations, not an admission budget. */
maxQueueWaitMs?: number;
codeApiMaxRetryWaitMs?: number;
/** Verified total HTTP budget; omission keeps the legacy per-attempt timeout. */
maxRequestTimeoutMs?: number;
fetchImpl?: CodeBridgeFetch;
Expand Down Expand Up @@ -395,6 +397,7 @@ export function createAttachedWorkspaceBashTool({
signal,
fetchImpl,
...(maxQueueWaitMs == null ? {} : { maxQueueWaitMs }),
...(codeApiMaxRetryWaitMs == null ? {} : { codeApiMaxRetryWaitMs }),
...(maxRequestTimeoutMs == null ? {} : { maxRequestTimeoutMs }),
});
if (result.operation !== 'execute_command') {
Expand Down
66 changes: 66 additions & 0 deletions packages/api/src/code/instructions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,72 @@ const response = () =>
);

describe('repository instruction loading', () => {
it('does not retry a typed 429 when the configured Code API wait budget is zero', async () => {
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'rate_limited', retry_after_seconds: 0 }), {
status: 429,
headers: { 'Retry-After': '0' },
}),
)
.mockResolvedValueOnce(response());

const result = await createRepositoryInstructionLoader()({
enabled: true,
context,
principalId: 'alice',
authHeaders: async () => ({}),
assertContent: jest.fn(),
codeApiMaxRetryWaitMs: 0,
fetchImpl,
});

expect(result).toBeUndefined();
expect(fetchImpl).toHaveBeenCalledTimes(1);
});

it('refreshes instruction-read authorization for a typed 429 retry', async () => {
jest.useFakeTimers();
try {
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'rate_limited' }), {
status: 429,
headers: { 'Retry-After': '0' },
}),
)
.mockResolvedValueOnce(response());
let minted = 0;
const authHeaders = jest.fn(async () => ({ Authorization: `Bearer token-${++minted}` }));
const load = createRepositoryInstructionLoader();
const input = {
enabled: true,
context,
principalId: 'alice',
authHeaders,
fetchImpl,
codeApiMaxRetryWaitMs: 100,
timeoutMs: 500,
assertContent: jest.fn(),
};
const result = load(input);
await jest.advanceTimersByTimeAsync(100);
await expect(result).resolves.toContain('Use the project test command.');
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls.map((call) => call[1]?.headers.Authorization)).toEqual([
'Bearer token-1',
'Bearer token-2',
]);
await expect(load(input)).resolves.toContain('Use the project test command.');
expect(authHeaders).toHaveBeenCalledTimes(3);
expect(fetchImpl).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});

it('bounds optional authorization waits and preserves explicit cancellation', async () => {
jest.useFakeTimers();
try {
Expand Down
Loading
Loading