Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1211,11 +1211,17 @@ OPENWEATHER_API_KEY=
# Agent Trigger Delivery #
#===========================#

# Base URL used by trusted in-process event producers to dispatch agent fires and steers.
# Base URL used by trusted in-process event producers to dispatch agent fires, continuations,
# and steers.
# Defaults to this process's bound listener. Set only when internal trigger admission must
# traverse another trusted HTTP origin, such as a TLS front door.
# AGENT_TRIGGERS_SELF_URL=http://127.0.0.1:3080

# Automatically continue a saved parent agent after a detached subagent settles.
# Rolling-deploy safety: deploy support with this disabled first, wait until every API
# replica is upgraded, then enable it in a subsequent rollout.
# ENABLE_SUBAGENT_COMPLETION_WAKEUPS=false

# Trusted event adapters enqueue through the shared durable trigger service. Mongo-backed
# leases make its workers safe across replicas; successful delivery records expire after
# 90 days, while dead letters remain available for explicit operator requeue.
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/cache-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,29 @@ jobs:
node-version: '24.16.0'

- name: Install Redis tools
timeout-minutes: 10
run: |
sudo apt-get update
sudo apt-get install -y redis-server redis-tools
# Same runner apt contention that broke the MCP job in
# playwright-mock.yml: apt-daily/unattended-upgrades hold
# /var/lib/apt/lists/lock at boot. Without a step timeout this hung
# until the job-level one fired, taking the whole leg with it.
sudo systemctl stop apt-daily.service apt-daily-upgrade.service \
unattended-upgrades.service 2>/dev/null || true
sudo systemctl kill --kill-who=all apt-daily.service \
apt-daily-upgrade.service 2>/dev/null || true

apt_with_lock_wait() {
for attempt in $(seq 1 30); do
if sudo apt-get -o DPkg::Lock::Timeout=60 "$@"; then
return 0
fi
echo "apt-get $1 could not take the lock (attempt ${attempt}/30), retrying"
sleep 10
done
return 1
}
apt_with_lock_wait update
apt_with_lock_wait install -y redis-server redis-tools

- name: Start Single Redis Instance
run: |
Expand Down
13 changes: 6 additions & 7 deletions .github/workflows/playwright-mock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,17 @@ jobs:
continue-on-error: true
run: timeout -k 10 90 npx playwright install ffmpeg

# Optional fonts only — see the note in the e2e_shards job.
- name: Install optional Playwright font dependencies (best effort)
timeout-minutes: 4
continue-on-error: true
run: .github/scripts/install-playwright-fonts.sh
# This job deliberately skips the optional font install: its bounded
# Playwright apt process can outlive the wrapper on a slow mirror and
# retain the package-manager lock needed by the required Redis install.
# The MCP suite does not enable visual snapshot assertions.

# Redis is a hard requirement for this job, so this step stays fatal.
- name: Install Redis runtime dependencies
timeout-minutes: 5
run: |
sudo apt-get update
sudo apt-get install -y redis-server redis-tools
sudo apt-get -o DPkg::Lock::Timeout=300 update
sudo apt-get -o DPkg::Lock::Timeout=300 install -y redis-server redis-tools

- name: Start standalone Redis and Redis Cluster
run: |
Expand Down
2 changes: 2 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
- **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat.
- **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence.
- **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store.
- **Theme definition**: a versioned, data-only description of LibreChat semantic colors and shared appearance roles, optionally specialized by light or dark mode. The theme module validates and resolves partial definitions against bundled defaults before adapters apply them. A theme definition does not contain arbitrary CSS, application behavior, or alternate feature layouts.
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.6.6",
"@librechat/agents": "^3.6.8",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
Expand Down
4 changes: 2 additions & 2 deletions api/server/controllers/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
findOpenIDUser,
getOpenIdIssuer,
buildOpenIDRefreshParams,
OPENID_EXPIRY_BUFFER_SECONDS,
} = require('@librechat/api');
const {
requestPasswordReset,
Expand All @@ -28,7 +29,6 @@ const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');

const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30;
/**
* Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh.
* Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like
Expand Down Expand Up @@ -110,7 +110,7 @@ const getReusableOpenIDSessionToken = (openidTokens) => {
if (
decoded &&
typeof decoded === 'object' &&
decoded.exp > now + OPENID_REUSE_EXPIRY_BUFFER_SECONDS
decoded.exp > now + OPENID_EXPIRY_BUFFER_SECONDS
) {
return candidate;
}
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/AuthController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
OPENID_EXPIRY_BUFFER_SECONDS: 30,
math: jest.fn((value, fallback) => fallback),
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
Expand Down
149 changes: 148 additions & 1 deletion api/server/controllers/agents/__tests__/callbacks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ jest.mock('nanoid', () => ({

jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
writeAttachmentEvent: jest.fn(),
GenerationJobManager: {
emitChunk: jest.fn(),
},
Expand Down Expand Up @@ -444,6 +445,7 @@ describe('createToolEndCallback', () => {
name,
toolName = 'execute_code',
hostFileAuthoring = false,
created,
codeExecutionContext,
}) {
return {
Expand All @@ -452,6 +454,8 @@ describe('createToolEndCallback', () => {
tool_call_id: toolCallId,
artifact: {
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
...(created === undefined ? {} : { created }),
path: name,
session_id: 'sess-1',
files: [{ id: fileId, name, session_id: 'sess-1' }],
},
Expand Down Expand Up @@ -667,8 +671,17 @@ describe('createToolEndCallback', () => {
conversationId: 'thread789',
messageId: 'run-create',
toolCallId: 'tool-create',
status: 'ready',
status: 'pending',
},
finalize: jest.fn().mockResolvedValue({
file_id: 'fid-created',
filename: 'created.txt',
filepath: '/uploads/created.txt',
type: 'text/plain',
conversationId: 'thread789',
messageId: 'run-create',
status: 'ready',
}),
});

const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
Expand All @@ -680,13 +693,15 @@ describe('createToolEndCallback', () => {
name: 'created.txt',
toolName: 'create_file',
hostFileAuthoring: true,
created: true,
codeExecutionContext: {
baseUrl: 'https://code-stateful.example.com',
executionProfile: 'stateful',
},
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
await new Promise((resolve) => setImmediate(resolve));

expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -699,7 +714,139 @@ describe('createToolEndCallback', () => {
executionProfile: 'stateful',
}),
);
expect(res.write).toHaveBeenCalledTimes(2);
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toEqual({
profile: 'stateful',
operation: 'created',
path: 'created.txt',
});
expect(parseSseAttachment(res.write.mock.calls[1]).workspaceChange).toEqual({
profile: 'stateful',
operation: 'created',
path: 'created.txt',
});
await expect(artifactPromises[0]).resolves.toEqual(
expect.objectContaining({
workspaceChange: {
profile: 'stateful',
operation: 'created',
path: 'created.txt',
},
}),
);
});

it('does not mark stateless file authoring outputs as stateful workspace changes', async () => {
res.headersSent = true;
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-default',
filename: 'default.txt',
filepath: '/uploads/default.txt',
type: 'text/plain',
conversationId: 'thread789',
messageId: 'run-default',
toolCallId: 'tool-default',
status: 'ready',
},
});

const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const event = makeCodeExecutionEvent({
runId: 'run-default',
threadId: 'thread789',
toolCallId: 'tool-default',
fileId: 'fid-default',
name: 'default.txt',
toolName: 'create_file',
hostFileAuthoring: true,
created: true,
codeExecutionContext: {
baseUrl: 'https://code-default.example.com',
executionProfile: 'default',
},
});
await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);

expect(res.write).toHaveBeenCalledTimes(1);
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toBeUndefined();
});

it('preserves stateful workspace changes in Open Responses attachment events', async () => {
const { writeAttachmentEvent } = require('@librechat/api');
const { createResponsesToolEndCallback } = require('../callbacks');
res.headersSent = true;
res.writableEnded = false;
processCodeOutput.mockResolvedValue({
file: {
file_id: 'fid-responses',
filename: 'summary.csv',
filepath: '/uploads/summary.csv',
type: 'text/csv',
conversationId: 'thread789',
messageId: 'run-responses',
toolCallId: 'tool-responses',
status: 'pending',
},
finalize: jest.fn().mockResolvedValue({
file_id: 'fid-responses',
filename: 'summary.csv',
filepath: '/uploads/summary.csv',
type: 'text/csv',
conversationId: 'thread789',
messageId: 'run-responses',
status: 'ready',
}),
});

const tracker = { nextSequence: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) };
const toolEndCallback = createResponsesToolEndCallback({
req,
res,
tracker,
artifactPromises,
});
const event = makeCodeExecutionEvent({
runId: 'run-responses',
threadId: 'thread789',
toolCallId: 'tool-responses',
fileId: 'fid-responses',
name: 'summary.csv',
toolName: 'edit_file',
hostFileAuthoring: true,
created: false,
codeExecutionContext: {
baseUrl: 'https://code-stateful.example.com',
executionProfile: 'stateful',
},
});
event.output.artifact.path = 'reports/summary.csv';

await toolEndCallback({ output: event.output }, event.metadata);
await Promise.all(artifactPromises);
await new Promise((resolve) => setImmediate(resolve));

expect(writeAttachmentEvent).toHaveBeenCalledTimes(2);
expect(writeAttachmentEvent.mock.calls[0][2].workspaceChange).toEqual({
profile: 'stateful',
operation: 'updated',
path: 'reports/summary.csv',
});
expect(writeAttachmentEvent.mock.calls[1][2].workspaceChange).toEqual({
profile: 'stateful',
operation: 'updated',
path: 'reports/summary.csv',
});
await expect(artifactPromises[0]).resolves.toEqual(
expect.objectContaining({
workspaceChange: {
profile: 'stateful',
operation: 'updated',
path: 'reports/summary.csv',
},
}),
);
});

it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
Expand Down
Loading
Loading