mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
Compare commits
155 commits
n8n@2.17.0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10dbf32596 | ||
|
|
4869e0a463 | ||
|
|
3bd7a2847c | ||
|
|
9494f41c34 | ||
|
|
713c4981b7 | ||
|
|
6db02fe928 | ||
|
|
a88f847708 | ||
|
|
7d74c1f04b | ||
|
|
b1ca129496 | ||
|
|
8e49800421 | ||
|
|
782b2d18b2 | ||
|
|
76358a60be | ||
|
|
86ceb68a05 | ||
|
|
2d624a521e | ||
|
|
ba2c5488c7 | ||
|
|
d1c7b31237 | ||
|
|
26ecadcf94 | ||
|
|
45b5b9e383 | ||
|
|
cb9882ce9c | ||
|
|
6592ed8047 | ||
|
|
92f1dac835 | ||
|
|
a88ee76553 | ||
|
|
b444a95e11 | ||
|
|
5e8002ab28 | ||
|
|
c012b52ac2 | ||
|
|
fc5424477d | ||
|
|
cb1244c041 | ||
|
|
6336f0a447 | ||
|
|
9ea2ef1840 | ||
|
|
5e111975d4 | ||
|
|
87163163e6 | ||
|
|
95c155859e | ||
|
|
575c34eae1 | ||
|
|
0d98d29ae4 | ||
|
|
9a65549575 | ||
|
|
dd6c28c6d1 | ||
|
|
d14f2546a1 | ||
|
|
d179f667c0 | ||
|
|
5b376cb12d | ||
|
|
6cfa0ed559 | ||
|
|
107c48f65c | ||
|
|
1b13d325f1 | ||
|
|
db83a95522 | ||
|
|
b42c890c5e | ||
|
|
3b15e470b5 | ||
|
|
bef528cb21 | ||
|
|
0b8fae6c5a | ||
|
|
560f300716 | ||
|
|
73d93d4edf | ||
|
|
9f71e12e5f | ||
|
|
9dd3e59acb | ||
|
|
657bdf136f | ||
|
|
2d0b231e31 | ||
|
|
c17f5b61fe | ||
|
|
db1eb91940 | ||
|
|
a3292b738a | ||
|
|
82ee4a9fce | ||
|
|
d608889e88 | ||
|
|
a39618a889 | ||
|
|
bfee79dc21 | ||
|
|
3e724303c5 | ||
|
|
19aadf19f7 | ||
|
|
7b3696f3f7 | ||
|
|
35f9bed4de | ||
|
|
b1c52dad58 | ||
|
|
d037fd4647 | ||
|
|
0fc2d90b52 | ||
|
|
b2fdcf16c0 | ||
|
|
73659cb3e7 | ||
|
|
4070930e4c | ||
|
|
e848230947 | ||
|
|
7094395cef | ||
|
|
f1dab3e295 | ||
|
|
ff950e5840 | ||
|
|
77d27bc826 | ||
|
|
25e07cab5a | ||
|
|
8c3e692174 | ||
|
|
ef4bfbfe94 | ||
|
|
51bc71e897 | ||
|
|
3b248eedc2 | ||
|
|
21317b8945 | ||
|
|
46aa46d996 | ||
|
|
5c9a732af4 | ||
|
|
cff2852332 | ||
|
|
465478a829 | ||
|
|
d17211342e | ||
|
|
bb96d2e50a | ||
|
|
c97c3b4d12 | ||
|
|
fb2bc1ca5f | ||
|
|
04860d5cd7 | ||
|
|
c6534fa0b3 | ||
|
|
bb9bec3ba4 | ||
|
|
56f36a6d19 | ||
|
|
e4fc753967 | ||
|
|
1ecc290107 | ||
|
|
6bb271d83c | ||
|
|
d012346c77 | ||
|
|
6739856aa3 | ||
|
|
b3e56437c8 | ||
|
|
e5aaeb53a9 | ||
|
|
8b105cc0cf | ||
|
|
34430aedb1 | ||
|
|
30128c9254 | ||
|
|
e20f8e91ce | ||
|
|
f216fda511 | ||
|
|
5368851506 | ||
|
|
80de266be4 | ||
|
|
57af37fc61 | ||
|
|
229256ee7c | ||
|
|
bb7d137cf7 | ||
|
|
62dc073b3d | ||
|
|
3f57f1cc19 | ||
|
|
819e707a61 | ||
|
|
04d57c5fd6 | ||
|
|
bd927d9350 | ||
|
|
1042350f4e | ||
|
|
f54608e6e4 | ||
|
|
9c97931ca0 | ||
|
|
ac41112731 | ||
|
|
2959b4dc2a | ||
|
|
36261fbe7a | ||
|
|
e849041c11 | ||
|
|
fa3299d042 | ||
|
|
24015b3449 | ||
|
|
59edd6ae54 | ||
|
|
ca871cc10a | ||
|
|
39189c3985 | ||
|
|
9ef55ca4f9 | ||
|
|
90a3f460f1 | ||
|
|
00b0558c2b | ||
|
|
094a5b403e | ||
|
|
c9cab112f9 | ||
|
|
dcbc3f14bd | ||
|
|
69a62e0906 | ||
|
|
357fb7210a | ||
|
|
98b833a07d | ||
|
|
b1a075f760 | ||
|
|
d6fbe5f847 | ||
|
|
d496f6f1bd | ||
|
|
bd9713bd67 | ||
|
|
9078bb2306 | ||
|
|
433370dc2f | ||
|
|
bbc3230dcf | ||
|
|
3c850f2711 | ||
|
|
b48aeef1f2 | ||
|
|
e8360a497d | ||
|
|
5f8ab01f9b | ||
|
|
9a22fe5a25 | ||
|
|
ca71d89d88 | ||
|
|
550409923a | ||
|
|
60503b60b1 | ||
|
|
df5855d4c6 | ||
|
|
1108467f44 | ||
|
|
39c6217109 | ||
|
|
6217d08ce9 |
1144 changed files with 49378 additions and 17514 deletions
11
.github/actions/docker-registry-login/action.yml
vendored
11
.github/actions/docker-registry-login/action.yml
vendored
|
|
@ -39,10 +39,13 @@ runs:
|
|||
|
||||
- name: Login to DockerHub
|
||||
if: inputs.login-dockerhub == 'true'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-username }}
|
||||
password: ${{ inputs.dockerhub-password }}
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_USER: ${{ inputs.dockerhub-username }}
|
||||
DOCKER_PASS: ${{ inputs.dockerhub-password }}
|
||||
run: |
|
||||
node .github/scripts/retry.mjs --attempts 3 --delay 10 \
|
||||
'echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin'
|
||||
|
||||
- name: Login to DHI Registry
|
||||
if: inputs.login-dhi == 'true'
|
||||
|
|
|
|||
108
.github/scripts/bump-versions.mjs
vendored
108
.github/scripts/bump-versions.mjs
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import semver from 'semver';
|
||||
import { parse } from 'yaml';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import child_process from 'child_process';
|
||||
|
|
@ -7,14 +8,19 @@ import assert from 'assert';
|
|||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
/**
|
||||
* @param {string | semver.SemVer} currentVersion
|
||||
*/
|
||||
function generateExperimentalVersion(currentVersion) {
|
||||
const parsed = semver.parse(currentVersion);
|
||||
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
|
||||
|
||||
// Check if it's already an experimental version
|
||||
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
|
||||
const minor = parsed.prerelease[1] || 0;
|
||||
const minorInt = typeof minor === 'string' ? parseInt(minor) : minor;
|
||||
// Increment the experimental minor version
|
||||
const expMinor = (parsed.prerelease[1] || 0) + 1;
|
||||
const expMinor = minorInt + 1;
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +29,10 @@ function generateExperimentalVersion(currentVersion) {
|
|||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const releaseType = process.env.RELEASE_TYPE;
|
||||
|
||||
const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ (
|
||||
process.env.RELEASE_TYPE
|
||||
);
|
||||
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
|
||||
|
||||
// TODO: if releaseType is `auto` determine release type based on the changelog
|
||||
|
|
@ -39,8 +48,12 @@ const packages = JSON.parse(
|
|||
|
||||
const packageMap = {};
|
||||
for (let { name, path, version, private: isPrivate } of packages) {
|
||||
if (isPrivate && path !== rootDir) continue;
|
||||
if (path === rootDir) name = 'monorepo-root';
|
||||
if (isPrivate && path !== rootDir) {
|
||||
continue;
|
||||
}
|
||||
if (path === rootDir) {
|
||||
name = 'monorepo-root';
|
||||
}
|
||||
|
||||
const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`)
|
||||
.then(() => false)
|
||||
|
|
@ -57,11 +70,94 @@ assert.ok(
|
|||
// Propagate isDirty transitively: if a package's dependency will be bumped,
|
||||
// that package also needs a bump (e.g. design-system → editor-ui → cli).
|
||||
|
||||
// Detect root-level changes that affect resolved dep versions without touching individual
|
||||
// package.json files: pnpm.overrides (applies to all specifiers)
|
||||
// and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier).
|
||||
|
||||
const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8'));
|
||||
const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`)
|
||||
.then(({ stdout }) => JSON.parse(stdout))
|
||||
.catch(() => ({}));
|
||||
|
||||
const getOverrides = (pkg) => ({ ...pkg.pnpm?.overrides, ...pkg.overrides });
|
||||
|
||||
const currentOverrides = getOverrides(rootPkgJson);
|
||||
const previousOverrides = getOverrides(rootPkgJsonAtTag);
|
||||
|
||||
const changedOverrides = new Set(
|
||||
Object.keys({ ...currentOverrides, ...previousOverrides }).filter(
|
||||
(k) => currentOverrides[k] !== previousOverrides[k],
|
||||
),
|
||||
);
|
||||
|
||||
const parseWorkspaceYaml = (content) => {
|
||||
try {
|
||||
return /** @type {Record<string, unknown>} */ (parse(content) ?? {});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const workspaceYaml = parseWorkspaceYaml(
|
||||
await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''),
|
||||
);
|
||||
const workspaceYamlAtTag = parseWorkspaceYaml(
|
||||
await exec(`git show ${lastTag}:pnpm-workspace.yaml`)
|
||||
.then(({ stdout }) => stdout)
|
||||
.catch(() => ''),
|
||||
);
|
||||
const getCatalogs = (ws) => {
|
||||
const result = new Map();
|
||||
if (ws.catalog) {
|
||||
result.set('default', /** @type {Record<string,string>} */ (ws.catalog));
|
||||
}
|
||||
|
||||
for (const [name, entries] of Object.entries(ws.catalogs ?? {})) {
|
||||
result.set(name, entries);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
// changedCatalogEntries: Map<catalogName, Set<depName>>
|
||||
const currentCatalogs = getCatalogs(workspaceYaml);
|
||||
const previousCatalogs = getCatalogs(workspaceYamlAtTag);
|
||||
const changedCatalogEntries = new Map();
|
||||
for (const catalogName of new Set([...currentCatalogs.keys(), ...previousCatalogs.keys()])) {
|
||||
const current = currentCatalogs.get(catalogName) ?? {};
|
||||
const previous = previousCatalogs.get(catalogName) ?? {};
|
||||
const changedDeps = new Set(
|
||||
Object.keys({ ...current, ...previous }).filter((dep) => current[dep] !== previous[dep]),
|
||||
);
|
||||
if (changedDeps.size > 0) {
|
||||
changedCatalogEntries.set(catalogName, changedDeps);
|
||||
}
|
||||
}
|
||||
|
||||
// Store full dep objects (with specifiers) so we can inspect "catalog:…" values below.
|
||||
const depsByPackage = {};
|
||||
for (const packageName in packageMap) {
|
||||
const packageFile = resolve(packageMap[packageName].path, 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
|
||||
depsByPackage[packageName] = Object.keys(packageJson.dependencies || {});
|
||||
depsByPackage[packageName] = /** @type {Record<string,string>} */ (
|
||||
packageJson.dependencies ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
// Mark packages dirty if any dep had a root-level override or catalog version change.
|
||||
for (const [packageName, deps] of Object.entries(depsByPackage)) {
|
||||
if (packageMap[packageName].isDirty) continue;
|
||||
for (const [dep, specifier] of Object.entries(deps)) {
|
||||
if (changedOverrides.has(dep)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
break;
|
||||
}
|
||||
if (typeof specifier === 'string' && specifier.startsWith('catalog:')) {
|
||||
const catalogName = specifier === 'catalog:' ? 'default' : specifier.slice(8);
|
||||
if (changedCatalogEntries.get(catalogName)?.has(dep)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
|
|
@ -69,7 +165,7 @@ while (changed) {
|
|||
changed = false;
|
||||
for (const packageName in packageMap) {
|
||||
if (packageMap[packageName].isDirty) continue;
|
||||
if (depsByPackage[packageName].some((dep) => packageMap[dep]?.isDirty)) {
|
||||
if (Object.keys(depsByPackage[packageName]).some((dep) => packageMap[dep]?.isDirty)) {
|
||||
packageMap[packageName].isDirty = true;
|
||||
changed = true;
|
||||
}
|
||||
|
|
|
|||
3
.github/scripts/package.json
vendored
3
.github/scripts/package.json
vendored
|
|
@ -11,7 +11,8 @@
|
|||
"glob": "13.0.6",
|
||||
"minimatch": "10.2.4",
|
||||
"semver": "7.7.4",
|
||||
"tempfile": "6.0.1"
|
||||
"tempfile": "6.0.1",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"conventional-changelog-angular": "8.3.0"
|
||||
|
|
|
|||
10
.github/scripts/pnpm-lock.yaml
vendored
10
.github/scripts/pnpm-lock.yaml
vendored
|
|
@ -32,6 +32,9 @@ importers:
|
|||
tempfile:
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
devDependencies:
|
||||
conventional-changelog-angular:
|
||||
specifier: 8.3.0
|
||||
|
|
@ -292,6 +295,11 @@ packages:
|
|||
wordwrap@1.0.0:
|
||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||
|
||||
yaml@2.8.3:
|
||||
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@actions/github@9.0.0':
|
||||
|
|
@ -540,3 +548,5 @@ snapshots:
|
|||
walk-up-path@4.0.0: {}
|
||||
|
||||
wordwrap@1.0.0: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
|
|
|||
25
.github/scripts/pnpm-utils.mjs
vendored
Normal file
25
.github/scripts/pnpm-utils.mjs
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import child_process from 'child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
/**
|
||||
* @typedef PnpmPackage
|
||||
* @property { string } name
|
||||
* @property { string } version
|
||||
* @property { string } path
|
||||
* @property { boolean } private
|
||||
* */
|
||||
|
||||
/**
|
||||
* @returns { Promise<PnpmPackage[]> }
|
||||
* */
|
||||
export async function getMonorepoProjects() {
|
||||
return JSON.parse(
|
||||
(
|
||||
await exec(
|
||||
`pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`,
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
}
|
||||
28
.github/scripts/set-latest-for-monorepo-packages.mjs
vendored
Normal file
28
.github/scripts/set-latest-for-monorepo-packages.mjs
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { trySh } from './github-helpers.mjs';
|
||||
import { getMonorepoProjects } from './pnpm-utils.mjs';
|
||||
|
||||
async function setLatestForMonorepoPackages() {
|
||||
const packages = await getMonorepoProjects();
|
||||
|
||||
const publishedPackages = packages //
|
||||
.filter((pkg) => !pkg.private)
|
||||
.filter((pkg) => pkg.version);
|
||||
|
||||
for (const pkg of publishedPackages) {
|
||||
const versionName = `${pkg.name}@${pkg.version}`;
|
||||
const res = trySh('npm', ['dist-tag', 'add', versionName, 'latest']);
|
||||
if (res.ok) {
|
||||
console.log(`Set ${versionName} as latest`);
|
||||
} else {
|
||||
console.warn(`Update failed for ${versionName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only run when executed directly, not when imported by tests
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
setLatestForMonorepoPackages().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
2
.github/workflows/ci-pr-quality.yml
vendored
2
.github/workflows/ci-pr-quality.yml
vendored
|
|
@ -48,6 +48,7 @@ jobs:
|
|||
# by checking the checkbox in the PR summary.
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'automation:backport') &&
|
||||
!contains(github.event.pull_request.title, '(backport to')
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -76,6 +77,7 @@ jobs:
|
|||
# Allows for override via '/size-limit-override' comment
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'automation:backport') &&
|
||||
!contains(github.event.pull_request.title, '(backport to')
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
20
.github/workflows/ci-pull-requests.yml
vendored
20
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -27,6 +27,7 @@ jobs:
|
|||
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
|
||||
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
|
||||
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
|
||||
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
|
||||
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -63,12 +64,19 @@ jobs:
|
|||
performance:
|
||||
packages/testing/performance/**
|
||||
packages/workflow/src/**
|
||||
packages/@n8n/expression-runtime/src/**
|
||||
.github/workflows/test-bench-reusable.yml
|
||||
e2e-performance:
|
||||
packages/testing/playwright/tests/performance/**
|
||||
packages/testing/playwright/utils/performance-helper.ts
|
||||
packages/testing/containers/**
|
||||
.github/workflows/test-e2e-performance-reusable.yml
|
||||
instance-ai-workflow-eval:
|
||||
packages/@n8n/instance-ai/src/**
|
||||
packages/@n8n/instance-ai/evaluations/**
|
||||
packages/cli/src/modules/instance-ai/**
|
||||
packages/core/src/execution-engine/eval-mock-helpers.ts
|
||||
.github/workflows/test-evals-instance-ai*.yml
|
||||
db:
|
||||
packages/cli/src/databases/**
|
||||
packages/cli/src/modules/*/database/**
|
||||
|
|
@ -195,6 +203,18 @@ jobs:
|
|||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
secrets: inherit
|
||||
|
||||
instance-ai-workflow-evals:
|
||||
name: Instance AI Workflow Evals
|
||||
needs: install-and-build
|
||||
if: >-
|
||||
needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
|
||||
github.repository == 'n8n-io/n8n' &&
|
||||
(github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
|
||||
uses: ./.github/workflows/test-evals-instance-ai.yml
|
||||
with:
|
||||
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
secrets: inherit
|
||||
|
||||
# This job is required by GitHub branch protection rules.
|
||||
# PRs cannot be merged unless this job passes.
|
||||
required-checks:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- 1.x
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
|
@ -46,7 +47,7 @@ jobs:
|
|||
`${marker}\n` +
|
||||
`🚫 **Merge blocked**: PRs into \`${base}\` are only allowed from branches named \`bundle/*\`.\n\n` +
|
||||
`Current source branch: \`${head}\`\n\n` +
|
||||
`Merge your developments into a bundle branch instead of directly merging to master.`;
|
||||
`Merge your developments into a bundle branch instead of directly merging to master or 1.x.`;
|
||||
|
||||
// Find an existing marker comment (to update instead of spamming)
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
|
|
@ -79,7 +80,7 @@ jobs:
|
|||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
echo "::error::You can only merge to master from a bundle/* branch. Got '$HEAD_REF'."
|
||||
echo "::error::You can only merge to master and 1.x from a bundle/* branch. Got '$HEAD_REF'."
|
||||
exit 1
|
||||
|
||||
- name: Allowed
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: 'Release: Create Minor Release PR'
|
|||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: 0 13 * * 1 # 2pm CET (UTC+1), Monday
|
||||
- cron: 0 8 * * 2 # 9am CET (UTC+1), Tuesday
|
||||
|
||||
jobs:
|
||||
create-release-pr:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ jobs:
|
|||
uses: ./.github/workflows/util-ensure-release-candidate-branches.yml
|
||||
secrets: inherit
|
||||
|
||||
ensure-correct-latest-version-on-npm:
|
||||
name: Ensure correct latest version on npm
|
||||
if: |
|
||||
inputs.bump == 'minor' ||
|
||||
inputs.track == 'stable'
|
||||
uses: ./.github/workflows/release-set-stable-npm-packages-to-latest.yml
|
||||
secrets: inherit
|
||||
|
||||
populate-cloud-with-releases:
|
||||
name: 'Populate cloud database with releases'
|
||||
uses: ./.github/workflows/release-populate-cloud-with-releases.yml
|
||||
|
|
|
|||
2
.github/workflows/release-publish.yml
vendored
2
.github/workflows/release-publish.yml
vendored
|
|
@ -84,7 +84,7 @@ jobs:
|
|||
- name: Publish other packages to NPM
|
||||
env:
|
||||
PUBLISH_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
PUBLISH_TAG: ${{ needs.determine-version-info.outputs.track == 'stable' && 'latest' || needs.determine-version-info.outputs.track }}
|
||||
PUBLISH_TAG: ${{ needs.determine-version-info.outputs.track }}
|
||||
run: |
|
||||
# Prefix version-like track names (e.g. "1", "v1") to avoid npm rejecting them as semver ranges
|
||||
if [[ "$PUBLISH_TAG" =~ ^v?[0-9] ]]; then
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: 'Release: Schedule Patch Release PRs'
|
|||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 8 * * 2-5' # 9am CET (UTC+1), Tuesday–Friday
|
||||
- cron: '0 8 * * 3-5,1' # 9am CET (UTC+1), Wednesday - Friday and Monday. (Minor release on tuesday)
|
||||
|
||||
jobs:
|
||||
create-patch-prs:
|
||||
|
|
|
|||
35
.github/workflows/release-set-stable-npm-packages-to-latest.yml
vendored
Normal file
35
.github/workflows/release-set-stable-npm-packages-to-latest.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: 'Release: Set stable npm packages to latest'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
promote-github-releases:
|
||||
name: Promote current stable releases as latest
|
||||
runs-on: ubuntu-slim
|
||||
environment: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: refs/tags/stable
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: ''
|
||||
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
|
||||
|
||||
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
|
||||
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Set npm packages to latest
|
||||
run: node ./.github/scripts/set-latest-for-monorepo-packages.mjs
|
||||
141
.github/workflows/test-evals-instance-ai.yml
vendored
Normal file
141
.github/workflows/test-evals-instance-ai.yml
vendored
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
name: 'Test: Instance AI Exec Evals'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'GitHub branch to test'
|
||||
required: false
|
||||
type: string
|
||||
default: 'master'
|
||||
filter:
|
||||
description: 'Filter test cases by name (e.g. "contact-form")'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'GitHub branch to test'
|
||||
required: false
|
||||
default: 'master'
|
||||
filter:
|
||||
description: 'Filter test cases by name (e.g. "contact-form")'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
run-evals:
|
||||
name: 'Run Evals'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Environment
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: 'pnpm build'
|
||||
|
||||
- name: Build Docker image
|
||||
run: pnpm build:docker
|
||||
env:
|
||||
INCLUDE_TEST_CONTROLLER: 'true'
|
||||
|
||||
- name: Start n8n container
|
||||
run: |
|
||||
docker run -d --name n8n-eval \
|
||||
-e E2E_TESTS=true \
|
||||
-e N8N_ENABLED_MODULES=instance-ai \
|
||||
-e N8N_AI_ENABLED=true \
|
||||
-e N8N_INSTANCE_AI_MODEL_API_KEY=${{ secrets.EVALS_ANTHROPIC_KEY }} \
|
||||
-e N8N_LICENSE_ACTIVATION_KEY=${{ secrets.N8N_LICENSE_ACTIVATION_KEY }} \
|
||||
-e N8N_LICENSE_CERT=${{ secrets.N8N_LICENSE_CERT }} \
|
||||
-e N8N_ENCRYPTION_KEY=${{ secrets.N8N_ENCRYPTION_KEY }} \
|
||||
-p 5678:5678 \
|
||||
n8nio/n8n:local
|
||||
echo "Waiting for n8n to be ready..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -s http://localhost:5678/healthz/readiness -o /dev/null -w "%{http_code}" | grep -q 200; then
|
||||
echo "n8n ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::n8n failed to start within 60s"
|
||||
docker logs n8n-eval --tail 30
|
||||
exit 1
|
||||
|
||||
- name: Create test user
|
||||
run: |
|
||||
curl -sf -X POST http://localhost:5678/rest/e2e/reset \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"owner":{"email":"nathan@n8n.io","password":"PlaywrightTest123","firstName":"Eval","lastName":"Owner"},
|
||||
"admin":{"email":"admin@n8n.io","password":"PlaywrightTest123","firstName":"Admin","lastName":"User"},
|
||||
"members":[],
|
||||
"chat":{"email":"chat@n8n.io","password":"PlaywrightTest123","firstName":"Chat","lastName":"User"}
|
||||
}'
|
||||
|
||||
- name: Run Instance AI Evals
|
||||
continue-on-error: true
|
||||
working-directory: packages/@n8n/instance-ai
|
||||
run: >-
|
||||
pnpm eval:instance-ai
|
||||
--base-url http://localhost:5678
|
||||
--verbose
|
||||
${{ inputs.filter && format('--filter "{0}"', inputs.filter) || '' }}
|
||||
env:
|
||||
N8N_INSTANCE_AI_MODEL_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
|
||||
|
||||
- name: Stop n8n container
|
||||
if: ${{ always() }}
|
||||
run: docker stop n8n-eval && docker rm n8n-eval || true
|
||||
|
||||
- name: Post eval results to PR
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RESULTS_FILE="packages/@n8n/instance-ai/eval-results.json"
|
||||
if [ ! -f "$RESULTS_FILE" ]; then
|
||||
echo "No eval results file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build the full comment body with jq
|
||||
jq -r '
|
||||
"### Instance AI Workflow Eval Results\n\n" +
|
||||
"**\(.summary.built)/\(.summary.testCases) built | \(.summary.scenariosPassed)/\(.summary.scenariosTotal) passed (\(.summary.passRate * 100 | floor)%)**\n\n" +
|
||||
"| Workflow | Build | Passed |\n|---|---|---|\n" +
|
||||
([.testCases[] | "| \(.name) | \(if .built then "✅" else "❌" end) | \([.scenarios[] | select(.passed)] | length)/\(.scenarios | length) |"] | join("\n")) +
|
||||
"\n\n<details><summary>Failure details</summary>\n\n" +
|
||||
([.testCases[].scenarios[] | select(.passed == false) | "**\(.name)** \(if .failureCategory then "[\(.failureCategory)]" else "" end)\n> \(.reasoning | .[0:200])\n"] | join("\n")) +
|
||||
"\n</details>"
|
||||
' "$RESULTS_FILE" > /tmp/eval-comment.md
|
||||
|
||||
# Find and update existing eval comment, or create new one
|
||||
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
|
||||
--jq '.[] | select(.body | startswith("### Instance AI Workflow Eval")) | .id' | tail -1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X PATCH -F body=@/tmp/eval-comment.md
|
||||
else
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body-file /tmp/eval-comment.md
|
||||
fi
|
||||
|
||||
- name: Upload Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: instance-ai-workflow-eval-results
|
||||
path: packages/@n8n/instance-ai/eval-results.json
|
||||
retention-days: 14
|
||||
2
.github/workflows/test-linting-reusable.yml
vendored
2
.github/workflows/test-linting-reusable.yml
vendored
|
|
@ -29,5 +29,5 @@ jobs:
|
|||
- name: Build and Test
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: pnpm lint
|
||||
build-command: pnpm lint:ci
|
||||
node-version: ${{ inputs.nodeVersion }}
|
||||
|
|
|
|||
3
.github/workflows/test-visual-storybook.yml
vendored
3
.github/workflows/test-visual-storybook.yml
vendored
|
|
@ -20,7 +20,8 @@ jobs:
|
|||
cloudflare:
|
||||
name: Cloudflare Pages
|
||||
if: |
|
||||
!contains(github.event.pull_request.labels.*.name, 'community')
|
||||
!contains(github.event.pull_request.labels.*.name, 'community') &&
|
||||
github.repository == 'n8n-io/n8n'
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ jobs:
|
|||
name: Post Metrics Comment
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
!github.event.pull_request.head.repo.fork
|
||||
!github.event.pull_request.head.repo.fork &&
|
||||
github.repository == 'n8n-io/n8n'
|
||||
runs-on: ubuntu-slim
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -33,6 +33,8 @@ test-results.json
|
|||
*.0x
|
||||
packages/testing/playwright/playwright-report
|
||||
packages/testing/playwright/test-results
|
||||
packages/testing/playwright/eval-results.json
|
||||
packages/@n8n/instance-ai/eval-results.json
|
||||
packages/testing/playwright/.playwright-browsers
|
||||
packages/testing/playwright/.playwright-cli
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ The monorepo is organized into these key packages:
|
|||
- **`packages/@n8n/i18n`**: Internationalization for UI text
|
||||
- **`packages/nodes-base`**: Built-in nodes for integrations
|
||||
- **`packages/@n8n/nodes-langchain`**: AI/LangChain nodes
|
||||
- **`packages/@n8n/instance-ai`**: "AI Assistant" in the UI, "Instance AI" in code — AI assistant backend. See its `CLAUDE.md` for architecture docs.
|
||||
- **`@n8n/design-system`**: Vue component library for UI consistency
|
||||
- **`@n8n/config`**: Centralized configuration management
|
||||
|
||||
|
|
|
|||
84
CHANGELOG.md
84
CHANGELOG.md
|
|
@ -1,3 +1,87 @@
|
|||
# [2.18.0](https://github.com/n8n-io/n8n/compare/n8n@2.17.0...n8n@2.18.0) (2026-04-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ai-builder:** Increase orchestrator max steps from default 5 to 60 ([#28429](https://github.com/n8n-io/n8n/issues/28429)) ([3c850f2](https://github.com/n8n-io/n8n/commit/3c850f2711d53ded62a3540c67b9ec02143cbb3f))
|
||||
* **ai-builder:** Scope artifacts panel to resources produced in-thread ([#28678](https://github.com/n8n-io/n8n/issues/28678)) ([7b3696f](https://github.com/n8n-io/n8n/commit/7b3696f3f7d95ab3cbaeb8ca58fdc74264a83b52))
|
||||
* **ai-builder:** Use placeholders for user-provided values instead of hardcoding fake addresses ([#28407](https://github.com/n8n-io/n8n/issues/28407)) ([39c6217](https://github.com/n8n-io/n8n/commit/39c62171092618149fa67ccb9a384a5a3aadd4e8))
|
||||
* **Alibaba Cloud Chat Model Node:** Add credential-level url field for AI gateway compatibility ([#28697](https://github.com/n8n-io/n8n/issues/28697)) ([dd6c28c](https://github.com/n8n-io/n8n/commit/dd6c28c6d16274354b83d3cc6a731f2f7a859a14))
|
||||
* **ClickUp Node:** Unclear error message when using OAuth credentials ([#28584](https://github.com/n8n-io/n8n/issues/28584)) ([19aadf1](https://github.com/n8n-io/n8n/commit/19aadf19f753d64cc2cd80af3c5b3dd957a4ede7))
|
||||
* **core:** Add required field validation to MCP OAuth client registration ([#28490](https://github.com/n8n-io/n8n/issues/28490)) ([8716316](https://github.com/n8n-io/n8n/commit/87163163e67001f69a2a2d7b4a650e0511614d62))
|
||||
* **core:** Cascade-cancel dependent planned tasks when a parent task fails ([#28656](https://github.com/n8n-io/n8n/issues/28656)) ([35f9bed](https://github.com/n8n-io/n8n/commit/35f9bed4de39350717192d9f272ad044ad50b323))
|
||||
* **core:** Enforce credential access checks in dynamic node parameter requests ([#28446](https://github.com/n8n-io/n8n/issues/28446)) ([ac41112](https://github.com/n8n-io/n8n/commit/ac411127314921aaf82b7b97d76eeaa2703b708c))
|
||||
* **core:** Ensure single zod instance across workspace packages ([#28604](https://github.com/n8n-io/n8n/issues/28604)) ([107c48f](https://github.com/n8n-io/n8n/commit/107c48f65c10d26f8f01d1bee5d2eb77b9d26084))
|
||||
* **core:** Filter stale credentials from setup wizard requests ([#28478](https://github.com/n8n-io/n8n/issues/28478)) ([657bdf1](https://github.com/n8n-io/n8n/commit/657bdf136fd0fc01cee3629baf65e130ee80840a))
|
||||
* **core:** Fix public API package update process ([#28475](https://github.com/n8n-io/n8n/issues/28475)) ([34430ae](https://github.com/n8n-io/n8n/commit/34430aedb15fa5305be475582e04f08967415e38))
|
||||
* **core:** Fix workflow-sdk validation for plain workflow objects ([#28416](https://github.com/n8n-io/n8n/issues/28416)) ([62dc073](https://github.com/n8n-io/n8n/commit/62dc073b3d954dc885359962c02ae8aa84d17c43))
|
||||
* **core:** Guard against undefined config properties in credential overwrites ([#28573](https://github.com/n8n-io/n8n/issues/28573)) ([77d27bc](https://github.com/n8n-io/n8n/commit/77d27bc826e4e91c2c589a62cbb6b997cacccd16))
|
||||
* **core:** Handle git fetch failure during source control startup ([#28422](https://github.com/n8n-io/n8n/issues/28422)) ([fa3299d](https://github.com/n8n-io/n8n/commit/fa3299d0425dfa2eaeaca6732dc46e0181e6fd68))
|
||||
* **core:** Handle invalid percent sequences and equals signs in HTTP response headers ([#27691](https://github.com/n8n-io/n8n/issues/27691)) ([ca71d89](https://github.com/n8n-io/n8n/commit/ca71d89d885d01f8663e29a2a5b1f06c713aede8))
|
||||
* **core:** Implement data tables name collision detection on pull ([#26416](https://github.com/n8n-io/n8n/issues/26416)) ([e5aaeb5](https://github.com/n8n-io/n8n/commit/e5aaeb53a93c63a04978e2a6eb7aa7255fcf510b))
|
||||
* **core:** Prevent nodes tool crash on flattened required fields ([#28670](https://github.com/n8n-io/n8n/issues/28670)) ([3e72430](https://github.com/n8n-io/n8n/commit/3e724303c537739319e91f8bcaf7070fe105ffc7))
|
||||
* **core:** Resolve additional keys lazily in VM expression engine ([#28430](https://github.com/n8n-io/n8n/issues/28430)) ([98b833a](https://github.com/n8n-io/n8n/commit/98b833a07d6d0f705633d7cb48298ee953688bd1))
|
||||
* **core:** Skip disabled Azure Key Vault secrets and handle partial fetch failures ([#28325](https://github.com/n8n-io/n8n/issues/28325)) ([6217d08](https://github.com/n8n-io/n8n/commit/6217d08ce9b53d6fd5277fa0708ed13d36e0e934))
|
||||
* **core:** Skip npm outdated check for verified-only community packages ([#28335](https://github.com/n8n-io/n8n/issues/28335)) ([2959b4d](https://github.com/n8n-io/n8n/commit/2959b4dc2a6cfd3733cc83bd6442dddd4cff08d2))
|
||||
* Disable axios built-in proxy for OAuth2 token requests ([#28513](https://github.com/n8n-io/n8n/issues/28513)) ([56f36a6](https://github.com/n8n-io/n8n/commit/56f36a6d1961d95780fb8258e8876d7d512503c2))
|
||||
* **editor:** Advance wizard step on Continue instead of applying setup ([#28698](https://github.com/n8n-io/n8n/issues/28698)) ([3b15e47](https://github.com/n8n-io/n8n/commit/3b15e470b54b13e9fe68e81c81a757c06b264783))
|
||||
* **editor:** Center sub-node icons and refresh triggers panel icons ([#28515](https://github.com/n8n-io/n8n/issues/28515)) ([6739856](https://github.com/n8n-io/n8n/commit/6739856aa32689b43d143ae4909e1f3d85dc4106))
|
||||
* **editor:** Display placeholder sentinels as hint text in setup wizard ([#28482](https://github.com/n8n-io/n8n/issues/28482)) ([bb7d137](https://github.com/n8n-io/n8n/commit/bb7d137cf735bcdf65bbcf8ff58fa911d83121f5))
|
||||
* **editor:** Gate Instance AI input while setup wizard is open ([#28685](https://github.com/n8n-io/n8n/issues/28685)) ([db83a95](https://github.com/n8n-io/n8n/commit/db83a95522957c10a3466f0b57944c8b8827347a))
|
||||
* **editor:** Hide setup parameter issue icons until user interacts with input ([#28010](https://github.com/n8n-io/n8n/issues/28010)) ([00b0558](https://github.com/n8n-io/n8n/commit/00b0558c2b1ed6bc4d47a86cb1bfca8eb55a47bc))
|
||||
* **editor:** Improve disabled Google sign-in button styling and tooltip alignment ([#28536](https://github.com/n8n-io/n8n/issues/28536)) ([e848230](https://github.com/n8n-io/n8n/commit/e8482309478eed05793dcaa4d82185936439663f))
|
||||
* **editor:** Improve setup wizard placeholder detection and card completion scoping ([#28474](https://github.com/n8n-io/n8n/issues/28474)) ([d172113](https://github.com/n8n-io/n8n/commit/d17211342e4ee8c8ec89a9c918017884e2de0763))
|
||||
* **editor:** Only show role assignment warning modal when value actually changed ([#28387](https://github.com/n8n-io/n8n/issues/28387)) ([9c97931](https://github.com/n8n-io/n8n/commit/9c97931ca06d407bec1c6a8bab510d206afba394))
|
||||
* **editor:** Prevent setup wizard disappearing on requestId-driven remount ([#28473](https://github.com/n8n-io/n8n/issues/28473)) ([04d57c5](https://github.com/n8n-io/n8n/commit/04d57c5fd62a5b9a2e086a3f540b7f50a932b62d))
|
||||
* **editor:** Re-initialize SSO store after login to populate OIDC redirect URL ([#28386](https://github.com/n8n-io/n8n/issues/28386)) ([21317b8](https://github.com/n8n-io/n8n/commit/21317b8945dec9169e36b7e5fdf867713018661d))
|
||||
* **editor:** Refine resource dependency badge ([#28087](https://github.com/n8n-io/n8n/issues/28087)) ([f216fda](https://github.com/n8n-io/n8n/commit/f216fda511062a40199b986351693677ebb2919e))
|
||||
* **editor:** Reset OIDC form dirty state after saving IdP settings ([#28388](https://github.com/n8n-io/n8n/issues/28388)) ([1042350](https://github.com/n8n-io/n8n/commit/1042350f4e0f6ed44b51a1d707de665f71437faa))
|
||||
* **editor:** Reset remote values on credentials change ([#26282](https://github.com/n8n-io/n8n/issues/26282)) ([5e11197](https://github.com/n8n-io/n8n/commit/5e111975d4086c060ac3d29d07da7c00ea2103a1))
|
||||
* **editor:** Resolve nodes stuck on loading after execution in instance-ai preview ([#28450](https://github.com/n8n-io/n8n/issues/28450)) ([c97c3b4](https://github.com/n8n-io/n8n/commit/c97c3b4d12e166091be9ea1de969a17d64c36ec2))
|
||||
* **editor:** Restore WASM file paths for cURL import in HTTP Request node ([#28610](https://github.com/n8n-io/n8n/issues/28610)) ([51bc71e](https://github.com/n8n-io/n8n/commit/51bc71e897e2baaf729963bf0f373a73505aee43))
|
||||
* **editor:** Show auth type selector in Instance AI workflow setup ([#28707](https://github.com/n8n-io/n8n/issues/28707)) ([1b13d32](https://github.com/n8n-io/n8n/commit/1b13d325f12a5a27d139c75164114ee41583a902))
|
||||
* **editor:** Show relevant node in workflow activation errors ([#26691](https://github.com/n8n-io/n8n/issues/26691)) ([c9cab11](https://github.com/n8n-io/n8n/commit/c9cab112f99a5da2742012773450bf7721484c28))
|
||||
* **Google Cloud Firestore Node:** Fix empty array serialization in jsonToDocument ([#28213](https://github.com/n8n-io/n8n/issues/28213)) ([7094395](https://github.com/n8n-io/n8n/commit/7094395cef8e71f767df6fa5e242cf2fa42366ed))
|
||||
* **Google Drive Node:** Continue on error support for download file operation ([#28276](https://github.com/n8n-io/n8n/issues/28276)) ([30128c9](https://github.com/n8n-io/n8n/commit/30128c9254be2214e746e0158296c1f1bd8ab4d8))
|
||||
* **Google Gemini Node:** Determine the file extention from MIME type for image and video operations ([#28616](https://github.com/n8n-io/n8n/issues/28616)) ([73659cb](https://github.com/n8n-io/n8n/commit/73659cb3e7eccd48a739829be0a4d7a6557ce4a1))
|
||||
* **GraphQL Node:** Improve error response handling ([#28209](https://github.com/n8n-io/n8n/issues/28209)) ([357fb72](https://github.com/n8n-io/n8n/commit/357fb7210ab201e13e2d3256a7886cf382656f22))
|
||||
* **HubSpot Node:** Rename HubSpot "App Token" auth to "Service Key" ([#28479](https://github.com/n8n-io/n8n/issues/28479)) ([8c3e692](https://github.com/n8n-io/n8n/commit/8c3e6921741f0e28ba28f8fb39797d5e19db71c9))
|
||||
* **HubSpot Trigger Node:** Add missing property selectors ([#28595](https://github.com/n8n-io/n8n/issues/28595)) ([d179f66](https://github.com/n8n-io/n8n/commit/d179f667c0044fd246d8e8535cd3a741d3f96b6f))
|
||||
* **IMAP Node:** Fix out-of-memory crash after ECONNRESET on reconnect ([#28290](https://github.com/n8n-io/n8n/issues/28290)) ([2d0b231](https://github.com/n8n-io/n8n/commit/2d0b231e31f265f39dd95d6794bd74d9b5592056))
|
||||
* Link to n8n website broken in n8n forms ([#28627](https://github.com/n8n-io/n8n/issues/28627)) ([ff950e5](https://github.com/n8n-io/n8n/commit/ff950e5840214c515d413b45f174d9638a51dd39))
|
||||
* **LinkedIn Node:** Update LinkedIn API version in request headers ([#28564](https://github.com/n8n-io/n8n/issues/28564)) ([25e07ca](https://github.com/n8n-io/n8n/commit/25e07cab5a66b04960753055131d355e0323d971))
|
||||
* **OpenAI Node:** Replace hardcoded models with RLC ([#28226](https://github.com/n8n-io/n8n/issues/28226)) ([4070930](https://github.com/n8n-io/n8n/commit/4070930e4c080c634df9b241175941c48afed9dc))
|
||||
* **Schedule Node:** Use elapsed-time check to self-heal after missed triggers ([#28423](https://github.com/n8n-io/n8n/issues/28423)) ([5f8ab01](https://github.com/n8n-io/n8n/commit/5f8ab01f9bb26f4d27f6f882fe1024f27caf4d67))
|
||||
* Update working memory using tools ([#28467](https://github.com/n8n-io/n8n/issues/28467)) ([39189c3](https://github.com/n8n-io/n8n/commit/39189c39859fbb4c1562a03ae3e6cd29195f7d1d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add deployment_key table, entity, repository, and migration ([#28329](https://github.com/n8n-io/n8n/issues/28329)) ([59edd6a](https://github.com/n8n-io/n8n/commit/59edd6ae5421aa6be34ee009a3024e0ca9843467))
|
||||
* Add Prometheus counters for token exchange ([#28453](https://github.com/n8n-io/n8n/issues/28453)) ([c6534fa](https://github.com/n8n-io/n8n/commit/c6534fa0b389a394e7591d3fc5ec565409279004))
|
||||
* AI Gateway credentials endpoint instance url ([#28520](https://github.com/n8n-io/n8n/issues/28520)) ([d012346](https://github.com/n8n-io/n8n/commit/d012346c777455de5bde9cab218f0c4f2d712fa0))
|
||||
* **API:** Add missing credential endpoints (GET by ID and test) ([#28519](https://github.com/n8n-io/n8n/issues/28519)) ([9a65549](https://github.com/n8n-io/n8n/commit/9a65549575bb201c3f55888d71e04663f622eb5b))
|
||||
* **core:** Add `require-node-description-fields` ESLint rule for icon and subtitle ([#28400](https://github.com/n8n-io/n8n/issues/28400)) ([5504099](https://github.com/n8n-io/n8n/commit/550409923a3d8d6961648674024eabb0d0749cfc))
|
||||
* **core:** Add KeyManagerService for encryption key lifecycle management ([#28533](https://github.com/n8n-io/n8n/issues/28533)) ([9dd3e59](https://github.com/n8n-io/n8n/commit/9dd3e59acb6eb94bb38ffe01677ea1c9a108d87b))
|
||||
* **core:** Configure OIDC settings via env vars ([#28185](https://github.com/n8n-io/n8n/issues/28185)) ([36261fb](https://github.com/n8n-io/n8n/commit/36261fbe7ad55a7b3bcc19809b6decb401b245bb))
|
||||
* **core:** Persist deployment_key entries for stability across restarts and key rotation ([#28518](https://github.com/n8n-io/n8n/issues/28518)) ([bb96d2e](https://github.com/n8n-io/n8n/commit/bb96d2e50a6b7cd77ea6256bb1446e8b3b348bd2))
|
||||
* **core:** Support npm dist-tags in community node installation ([#28067](https://github.com/n8n-io/n8n/issues/28067)) ([ca871cc](https://github.com/n8n-io/n8n/commit/ca871cc10aca97de8c0892e0735c9fa2ed16d251))
|
||||
* **core:** Support npm registry token authentication to install private community node packages ([#28228](https://github.com/n8n-io/n8n/issues/28228)) ([8b105cc](https://github.com/n8n-io/n8n/commit/8b105cc0cf6e84e069f6b7f3a98c334cd44876c1))
|
||||
* **core:** Track workflow action source for external API and MCP requests ([#28483](https://github.com/n8n-io/n8n/issues/28483)) ([575c34e](https://github.com/n8n-io/n8n/commit/575c34eae1bdf8e9d5d5fe7d31c92f57f27fcc27))
|
||||
* **core:** Workflow tracing - add workflow version id ([#28424](https://github.com/n8n-io/n8n/issues/28424)) ([9a22fe5](https://github.com/n8n-io/n8n/commit/9a22fe5a255b20be7d0e78fff7e03bf79e50a62f))
|
||||
* **editor:** Add favoriting for projects, folders, workflows and data tables ([#26228](https://github.com/n8n-io/n8n/issues/26228)) ([b1a075f](https://github.com/n8n-io/n8n/commit/b1a075f7609045620563f86df0e15d27b1176d45))
|
||||
* **editor:** Enable workflow execution from instance AI preview canvas ([#28412](https://github.com/n8n-io/n8n/issues/28412)) ([5b376cb](https://github.com/n8n-io/n8n/commit/5b376cb12d6331e4e458a1f1880fcddce76d1db9))
|
||||
* Enable security policy settings via env vars ([#28321](https://github.com/n8n-io/n8n/issues/28321)) ([1108467](https://github.com/n8n-io/n8n/commit/1108467f44bf987c0f5a5a0eafb6396e2745b8ce))
|
||||
* **Linear Trigger Node:** Add signing secret validation ([#28522](https://github.com/n8n-io/n8n/issues/28522)) ([3b248ee](https://github.com/n8n-io/n8n/commit/3b248eedc289c62f32f16da677c75b25df0fcb9f))
|
||||
* **MiniMax Chat Model Node:** Add MiniMax Chat Model sub-node ([#28305](https://github.com/n8n-io/n8n/issues/28305)) ([bd927d9](https://github.com/n8n-io/n8n/commit/bd927d93503a65e0be18c4c40e68dcad96f68d82))
|
||||
* **Slack Node:** Add app_home_opened as a dedicated trigger event ([#28626](https://github.com/n8n-io/n8n/issues/28626)) ([f1dab3e](https://github.com/n8n-io/n8n/commit/f1dab3e29530ee596d68db474024ddbae5fa055a))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* Make Wait node fully durable by removing in-memory execution path ([#28538](https://github.com/n8n-io/n8n/issues/28538)) ([bb9bec3](https://github.com/n8n-io/n8n/commit/bb9bec3ba419d46450122411839f20cd614db920))
|
||||
|
||||
|
||||
# [2.17.0](https://github.com/n8n-io/n8n/compare/n8n@2.16.0...n8n@2.17.0) (2026-04-13)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"**/CHANGELOG.md",
|
||||
"**/cl100k_base.json",
|
||||
"**/o200k_base.json",
|
||||
"**/*.generated.ts"
|
||||
"**/*.generated.ts",
|
||||
"**/expectations/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.17.0",
|
||||
"version": "2.18.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.16",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"typecheck": "turbo typecheck",
|
||||
"dev": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
||||
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/n8n-nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"",
|
||||
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
|
||||
"dev:e2e": "pnpm --filter=n8n-playwright dev --ui",
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"lint:styles:fix": "turbo run lint:styles:fix",
|
||||
"lint:affected": "turbo run lint --affected",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"lint:ci": "turbo run lint lint:styles",
|
||||
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
|
||||
"generate:third-party-licenses": "node scripts/generate-third-party-licenses.mjs",
|
||||
"setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs",
|
||||
|
|
@ -102,7 +103,7 @@
|
|||
"@mistralai/mistralai": "^1.10.0",
|
||||
"@n8n/typeorm>@sentry/node": "catalog:sentry",
|
||||
"@types/node": "^20.17.50",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.15.0",
|
||||
"chokidar": "4.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"expr-eval@2.0.2": "npm:expr-eval-fork@3.0.0",
|
||||
|
|
|
|||
|
|
@ -367,10 +367,11 @@ At end of turn, `saveToMemory()` uses `list.turnDelta()` and
|
|||
`saveMessagesToThread`. If **semantic recall** is configured with an embedder
|
||||
and `memory.saveEmbeddings`, new messages are embedded and stored.
|
||||
|
||||
**Working memory:** when configured, the runtime parses `<working_memory>` …
|
||||
`</working_memory>` regions from assistant text, validates structured JSON if a
|
||||
schema exists, strips the tags from the visible message, and asynchronously
|
||||
persists via `memory.saveWorkingMemory`.
|
||||
**Working memory:** when configured, the runtime injects an `updateWorkingMemory`
|
||||
tool into the agent's tool set. The current state is included in the system prompt
|
||||
so the model can read it; when new information should be persisted the model calls
|
||||
the tool, which validates the input and asynchronously persists via
|
||||
`memory.saveWorkingMemory`.
|
||||
|
||||
**Thread titles:** `titleGeneration` triggers `generateThreadTitle` (fire-and-forget)
|
||||
after a successful save when persistence and memory are present.
|
||||
|
|
@ -414,7 +415,7 @@ src/
|
|||
tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards
|
||||
stream.ts — convertChunk, toTokenUsage
|
||||
runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, …
|
||||
working-memory.ts — instruction text, parse/filter for working_memory tags
|
||||
working-memory.ts — instruction text, updateWorkingMemory tool builder
|
||||
strip-orphaned-tool-messages.ts
|
||||
title-generation.ts
|
||||
logger.ts
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ describe('custom BuiltMemory backend', () => {
|
|||
expect(findLastTextContent(result.messages)?.toLowerCase()).not.toContain('aurora');
|
||||
|
||||
// Thread 2 working memory should be independent
|
||||
expect(store.workingMemory.get(thread2)).not.toContain('aurora');
|
||||
expect(store.workingMemory.get(thread2)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('thread-scoped working memory allows recall within the same thread when history is truncated', async () => {
|
||||
|
|
|
|||
|
|
@ -32,26 +32,24 @@ describe('freeform working memory', () => {
|
|||
expect(findLastTextContent(result.messages)?.toLowerCase()).toContain('berlin');
|
||||
});
|
||||
|
||||
it('working memory tags are stripped from visible response', async () => {
|
||||
it('working memory is updated when new information is provided', async () => {
|
||||
const memory = new Memory().storage('memory').lastMessages(10).freeform(template);
|
||||
|
||||
const agent = new Agent('strip-test')
|
||||
const agent = new Agent('wm-update-test')
|
||||
.model(getModel('anthropic'))
|
||||
.instructions('You are a helpful assistant. Be concise.')
|
||||
.memory(memory);
|
||||
|
||||
const threadId = `strip-${Date.now()}`;
|
||||
const threadId = `wm-update-${Date.now()}`;
|
||||
const options = { persistence: { threadId, resourceId: 'test-user' } };
|
||||
|
||||
const result = await agent.generate('My name is Bob.', options);
|
||||
|
||||
const allText = result.messages
|
||||
.flatMap((m) => ('content' in m ? m.content : []))
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join(' ');
|
||||
expect(allText).not.toContain('<working_memory>');
|
||||
expect(allText).not.toContain('</working_memory>');
|
||||
const toolCalls = result.messages.flatMap((m) =>
|
||||
'content' in m ? m.content.filter((c) => c.type === 'tool-call') : [],
|
||||
) as Array<{ type: 'tool-call'; toolName: string }>;
|
||||
const wmToolCall = toolCalls.find((c) => c.toolName === 'updateWorkingMemory');
|
||||
expect(wmToolCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('working memory persists across threads with same resourceId', async () => {
|
||||
|
|
|
|||
|
|
@ -2,27 +2,56 @@ import type { LanguageModel } from 'ai';
|
|||
|
||||
import { createModel } from '../runtime/model-factory';
|
||||
|
||||
type ProviderOpts = {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
jest.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: (opts?: { apiKey?: string; baseURL?: string }) => (model: string) => ({
|
||||
createAnthropic: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'anthropic',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
baseURL: opts?.baseURL,
|
||||
fetch: opts?.fetch,
|
||||
headers: opts?.headers,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@ai-sdk/openai', () => ({
|
||||
createOpenAI: (opts?: { apiKey?: string; baseURL?: string }) => (model: string) => ({
|
||||
createOpenAI: (opts?: ProviderOpts) => (model: string) => ({
|
||||
provider: 'openai',
|
||||
modelId: model,
|
||||
apiKey: opts?.apiKey,
|
||||
baseURL: opts?.baseURL,
|
||||
fetch: opts?.fetch,
|
||||
headers: opts?.headers,
|
||||
specificationVersion: 'v3',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockProxyAgent = jest.fn();
|
||||
jest.mock('undici', () => ({
|
||||
ProxyAgent: mockProxyAgent,
|
||||
}));
|
||||
|
||||
describe('createModel', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
mockProxyAgent.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should accept a string config', () => {
|
||||
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('anthropic');
|
||||
|
|
@ -63,4 +92,42 @@ describe('createModel', () => {
|
|||
expect(model.provider).toBe('openai');
|
||||
expect(model.modelId).toBe('ft:gpt-4o:my-org:custom:abc123');
|
||||
});
|
||||
|
||||
it('should not pass fetch when no proxy env vars are set', () => {
|
||||
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
|
||||
expect(model.fetch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass proxy-aware fetch when HTTPS_PROXY is set', () => {
|
||||
process.env.HTTPS_PROXY = 'http://proxy:8080';
|
||||
const model = createModel('anthropic/claude-sonnet-4-5') as unknown as Record<string, unknown>;
|
||||
expect(model.fetch).toBeInstanceOf(Function);
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy:8080');
|
||||
});
|
||||
|
||||
it('should pass proxy-aware fetch when HTTP_PROXY is set', () => {
|
||||
process.env.HTTP_PROXY = 'http://proxy:9090';
|
||||
const model = createModel('openai/gpt-4o') as unknown as Record<string, unknown>;
|
||||
expect(model.fetch).toBeInstanceOf(Function);
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy:9090');
|
||||
});
|
||||
|
||||
it('should forward custom headers to the provider factory', () => {
|
||||
const model = createModel({
|
||||
id: 'anthropic/claude-sonnet-4-5',
|
||||
apiKey: 'sk-test',
|
||||
headers: { 'x-proxy-auth': 'Bearer abc', 'anthropic-beta': 'tools-2024' },
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.headers).toEqual({
|
||||
'x-proxy-auth': 'Bearer abc',
|
||||
'anthropic-beta': 'tools-2024',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer HTTPS_PROXY over HTTP_PROXY', () => {
|
||||
process.env.HTTPS_PROXY = 'http://https-proxy:8080';
|
||||
process.env.HTTP_PROXY = 'http://http-proxy:9090';
|
||||
createModel('anthropic/claude-sonnet-4-5');
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith('http://https-proxy:8080');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
123
packages/@n8n/agents/src/__tests__/title-generation.test.ts
Normal file
123
packages/@n8n/agents/src/__tests__/title-generation.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type * as AiImport from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
import { generateTitleFromMessage } from '../runtime/title-generation';
|
||||
|
||||
type GenerateTextCall = {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
const mockGenerateText = jest.fn<Promise<{ text: string }>, [GenerateTextCall]>();
|
||||
|
||||
jest.mock('ai', () => {
|
||||
const actual = jest.requireActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (call: GenerateTextCall): Promise<{ text: string }> =>
|
||||
await mockGenerateText(call),
|
||||
};
|
||||
});
|
||||
|
||||
const fakeModel = {} as LanguageModel;
|
||||
|
||||
describe('generateTitleFromMessage', () => {
|
||||
beforeEach(() => {
|
||||
mockGenerateText.mockReset();
|
||||
});
|
||||
|
||||
it('returns null for empty input without calling the LLM', async () => {
|
||||
const result = await generateTitleFromMessage(fakeModel, ' ');
|
||||
expect(result).toBeNull();
|
||||
expect(mockGenerateText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for trivial greetings without calling the LLM', async () => {
|
||||
const result = await generateTitleFromMessage(fakeModel, 'hey');
|
||||
expect(result).toBeNull();
|
||||
expect(mockGenerateText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for short multi-word messages without calling the LLM', async () => {
|
||||
const result = await generateTitleFromMessage(fakeModel, 'hi there');
|
||||
expect(result).toBeNull();
|
||||
expect(mockGenerateText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips markdown heading prefixes from the LLM response', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '# Daily Berlin rain alert' });
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Build a daily Berlin rain alert workflow',
|
||||
);
|
||||
expect(result).toBe('Daily Berlin rain alert');
|
||||
});
|
||||
|
||||
it('strips inline emphasis markers from the LLM response', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'Your **Berlin** rain alert' });
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Build a daily Berlin rain alert workflow',
|
||||
);
|
||||
expect(result).toBe('Your Berlin rain alert');
|
||||
});
|
||||
|
||||
it('strips <think> reasoning blocks from the LLM response', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '<think>Let me think about this</think>Deploy release pipeline',
|
||||
});
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Help me set up an automated deploy pipeline',
|
||||
);
|
||||
expect(result).toBe('Deploy release pipeline');
|
||||
});
|
||||
|
||||
it('strips surrounding quotes from the LLM response', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '"Build Gmail to Slack workflow"' });
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Build a workflow that forwards Gmail to Slack',
|
||||
);
|
||||
expect(result).toBe('Build Gmail to Slack workflow');
|
||||
});
|
||||
|
||||
it('truncates titles longer than 80 characters at a word boundary', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: 'Create a data table for users, then build a workflow that syncs them to our CRM every hour',
|
||||
});
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Create a data table for users and sync them to our CRM every hour with error alerting',
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBeLessThanOrEqual(81);
|
||||
expect(result!.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when the LLM returns empty text', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: ' ' });
|
||||
const result = await generateTitleFromMessage(
|
||||
fakeModel,
|
||||
'Build a daily Berlin rain alert workflow',
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the default instructions to the LLM', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' });
|
||||
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow');
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.messages[0].role).toBe('system');
|
||||
expect(call.messages[0].content).toContain('markdown');
|
||||
expect(call.messages[0].content).toContain('sentence case');
|
||||
});
|
||||
|
||||
it('accepts custom instructions', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: 'Custom title' });
|
||||
await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow', {
|
||||
instructions: 'Custom system prompt',
|
||||
});
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.messages[0].content).toBe('Custom system prompt');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,62 +1,74 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
parseWorkingMemory,
|
||||
buildWorkingMemoryInstruction,
|
||||
buildWorkingMemoryTool,
|
||||
templateFromSchema,
|
||||
WorkingMemoryStreamFilter,
|
||||
UPDATE_WORKING_MEMORY_TOOL_NAME,
|
||||
WORKING_MEMORY_DEFAULT_INSTRUCTION,
|
||||
} from '../runtime/working-memory';
|
||||
import type { StreamChunk } from '../types';
|
||||
|
||||
describe('parseWorkingMemory', () => {
|
||||
it('extracts content between tags at end of text', () => {
|
||||
const text = 'Hello world.\n<working_memory>\n# Name: Alice\n</working_memory>';
|
||||
const result = parseWorkingMemory(text);
|
||||
expect(result.cleanText).toBe('Hello world.');
|
||||
expect(result.workingMemory).toBe('# Name: Alice');
|
||||
});
|
||||
|
||||
it('extracts content between tags in middle of text', () => {
|
||||
const text = 'Before.\n<working_memory>\ndata\n</working_memory>\nAfter.';
|
||||
const result = parseWorkingMemory(text);
|
||||
expect(result.cleanText).toBe('Before.\nAfter.');
|
||||
expect(result.workingMemory).toBe('data');
|
||||
});
|
||||
|
||||
it('returns null when no tags present', () => {
|
||||
const text = 'Just a normal response.';
|
||||
const result = parseWorkingMemory(text);
|
||||
expect(result.cleanText).toBe('Just a normal response.');
|
||||
expect(result.workingMemory).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty working memory', () => {
|
||||
const text = 'Response.\n<working_memory>\n</working_memory>';
|
||||
const result = parseWorkingMemory(text);
|
||||
expect(result.cleanText).toBe('Response.');
|
||||
expect(result.workingMemory).toBe('');
|
||||
});
|
||||
|
||||
it('handles multiline content with markdown', () => {
|
||||
const wm = '# User Context\n- **Name**: Alice\n- **City**: Berlin';
|
||||
const text = `Response text.\n<working_memory>\n${wm}\n</working_memory>`;
|
||||
const result = parseWorkingMemory(text);
|
||||
expect(result.workingMemory).toBe(wm);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkingMemoryInstruction', () => {
|
||||
it('generates freeform instruction', () => {
|
||||
it('mentions the updateWorkingMemory tool name', () => {
|
||||
const result = buildWorkingMemoryInstruction('# Context\n- Name:', false);
|
||||
expect(result).toContain('<working_memory>');
|
||||
expect(result).toContain('</working_memory>');
|
||||
expect(result).toContain('# Context\n- Name:');
|
||||
expect(result).toContain(UPDATE_WORKING_MEMORY_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('generates structured instruction mentioning JSON', () => {
|
||||
const result = buildWorkingMemoryInstruction('{"userName": ""}', true);
|
||||
it('instructs the model to call the tool only when something changed', () => {
|
||||
const result = buildWorkingMemoryInstruction('# Context\n- Name:', false);
|
||||
expect(result).toContain('Only call it when something has actually changed');
|
||||
});
|
||||
|
||||
it('includes the template in the instruction', () => {
|
||||
const template = '# Context\n- Name:\n- City:';
|
||||
const result = buildWorkingMemoryInstruction(template, false);
|
||||
expect(result).toContain(template);
|
||||
});
|
||||
|
||||
it('mentions JSON for structured variant', () => {
|
||||
const result = buildWorkingMemoryInstruction('{"name": ""}', true);
|
||||
expect(result).toContain('JSON');
|
||||
expect(result).toContain('<working_memory>');
|
||||
});
|
||||
|
||||
describe('custom instruction', () => {
|
||||
it('replaces the default instruction body when provided', () => {
|
||||
const custom = 'Always update working memory after every message.';
|
||||
const result = buildWorkingMemoryInstruction('# Template', false, custom);
|
||||
expect(result).toContain(custom);
|
||||
expect(result).not.toContain(WORKING_MEMORY_DEFAULT_INSTRUCTION);
|
||||
});
|
||||
|
||||
it('still includes the ## Working Memory heading', () => {
|
||||
const result = buildWorkingMemoryInstruction('# Template', false, 'Custom text.');
|
||||
expect(result).toContain('## Working Memory');
|
||||
});
|
||||
|
||||
it('still includes the template block', () => {
|
||||
const template = '# Context\n- Name:\n- City:';
|
||||
const result = buildWorkingMemoryInstruction(template, false, 'Custom text.');
|
||||
expect(result).toContain(template);
|
||||
});
|
||||
|
||||
it('still includes the format hint for structured memory', () => {
|
||||
const result = buildWorkingMemoryInstruction('{}', true, 'Custom text.');
|
||||
expect(result).toContain('JSON');
|
||||
});
|
||||
|
||||
it('still includes the format hint for freeform memory', () => {
|
||||
const result = buildWorkingMemoryInstruction('# Template', false, 'Custom text.');
|
||||
expect(result).toContain('Update the template with any new information learned');
|
||||
});
|
||||
|
||||
it('uses the default instruction when undefined is passed explicitly', () => {
|
||||
const withDefault = buildWorkingMemoryInstruction('# Template', false, undefined);
|
||||
const withoutArg = buildWorkingMemoryInstruction('# Template', false);
|
||||
expect(withDefault).toBe(withoutArg);
|
||||
});
|
||||
|
||||
it('WORKING_MEMORY_DEFAULT_INSTRUCTION appears in the output when no custom instruction is set', () => {
|
||||
const result = buildWorkingMemoryInstruction('# Template', false);
|
||||
expect(result).toContain(WORKING_MEMORY_DEFAULT_INSTRUCTION);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +81,6 @@ describe('templateFromSchema', () => {
|
|||
const result = templateFromSchema(schema);
|
||||
expect(result).toContain('userName');
|
||||
expect(result).toContain('favoriteColor');
|
||||
// Should be valid JSON
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result);
|
||||
|
|
@ -80,118 +91,117 @@ describe('templateFromSchema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper that feeds chunks through a WorkingMemoryStreamFilter and collects
|
||||
* the output text and any persisted working memory content.
|
||||
*/
|
||||
async function runStreamFilter(
|
||||
chunks: string[],
|
||||
): Promise<{ outputText: string; persisted: string[] }> {
|
||||
const persisted: string[] = [];
|
||||
const stream = new TransformStream<StreamChunk>();
|
||||
const writer = stream.writable.getWriter();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const filter = new WorkingMemoryStreamFilter(writer, async (content) => {
|
||||
persisted.push(content);
|
||||
describe('buildWorkingMemoryTool — freeform', () => {
|
||||
it('returns a BuiltTool with the correct name', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: false,
|
||||
persist: async () => {},
|
||||
});
|
||||
expect(tool.name).toBe(UPDATE_WORKING_MEMORY_TOOL_NAME);
|
||||
});
|
||||
|
||||
// Read the readable side concurrently to avoid backpressure deadlock
|
||||
const reader = stream.readable.getReader();
|
||||
const readAll = (async () => {
|
||||
let outputText = '';
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
const chunk = result.value as StreamChunk;
|
||||
if (chunk.type === 'text-delta') outputText += chunk.delta;
|
||||
}
|
||||
return outputText;
|
||||
})();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await filter.write({ type: 'text-delta', delta: chunk });
|
||||
}
|
||||
await filter.flush();
|
||||
await writer.close();
|
||||
|
||||
const outputText = await readAll;
|
||||
return { outputText, persisted };
|
||||
}
|
||||
|
||||
describe('WorkingMemoryStreamFilter with tag split across multiple chunks', () => {
|
||||
it('handles tag split mid-open-tag', async () => {
|
||||
const { outputText, persisted } = await runStreamFilter([
|
||||
'Hello <work',
|
||||
'ing_memory>state</working_memory>',
|
||||
]);
|
||||
expect(outputText).toBe('Hello ');
|
||||
expect(persisted).toEqual(['state']);
|
||||
it('has a description', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: false,
|
||||
persist: async () => {},
|
||||
});
|
||||
expect(tool.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles tag split mid-close-tag', async () => {
|
||||
const { outputText, persisted } = await runStreamFilter([
|
||||
'<working_memory>state</worki',
|
||||
'ng_memory> after',
|
||||
]);
|
||||
expect(persisted).toEqual(['state']);
|
||||
expect(outputText).toBe(' after');
|
||||
it('has a freeform input schema with a memory field', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: false,
|
||||
persist: async () => {},
|
||||
});
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
const schema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
|
||||
const result = schema.safeParse({ memory: 'hello' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles tag spread across 3+ chunks', async () => {
|
||||
const { outputText, persisted } = await runStreamFilter([
|
||||
'<wor',
|
||||
'king_mem',
|
||||
'ory>data</working_memory>',
|
||||
]);
|
||||
expect(persisted).toEqual(['data']);
|
||||
expect(outputText).toBe('');
|
||||
it('rejects input without memory field', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: false,
|
||||
persist: async () => {},
|
||||
});
|
||||
const schema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
|
||||
const result = schema.safeParse({ other: 'value' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('handles partial < that is not a tag', async () => {
|
||||
const { outputText, persisted } = await runStreamFilter(['Hello <', 'div>world']);
|
||||
expect(outputText).toBe('Hello <div>world');
|
||||
expect(persisted).toEqual([]);
|
||||
it('handler calls persist with the memory string', async () => {
|
||||
const persisted: string[] = [];
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: false,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
persist: async (content) => {
|
||||
persisted.push(content);
|
||||
},
|
||||
});
|
||||
const result = await tool.handler!({ memory: 'test content' }, {} as never);
|
||||
expect(persisted).toEqual(['test content']);
|
||||
expect(result).toMatchObject({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWorkingMemory with invalid structured content', () => {
|
||||
it('strips tags and extracts content regardless of JSON validity', () => {
|
||||
const invalidJson = '{not valid json!!!}';
|
||||
const text = `Here is my response.\n<working_memory>\n${invalidJson}\n</working_memory>`;
|
||||
const result = parseWorkingMemory(text);
|
||||
|
||||
expect(result.cleanText).toBe('Here is my response.');
|
||||
expect(result.workingMemory).toBe(invalidJson);
|
||||
describe('buildWorkingMemoryTool — structured', () => {
|
||||
const schema = z.object({
|
||||
userName: z.string().optional().describe("The user's name"),
|
||||
location: z.string().optional().describe('Where the user lives'),
|
||||
});
|
||||
|
||||
it('strips tags with content that fails Zod schema validation', () => {
|
||||
// Content is valid JSON but wrong shape for the schema
|
||||
const wrongShape = '{"unexpected": true}';
|
||||
const text = `Response text.\n<working_memory>\n${wrongShape}\n</working_memory>`;
|
||||
const result = parseWorkingMemory(text);
|
||||
it('uses the Zod schema as input schema', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: true,
|
||||
schema,
|
||||
persist: async () => {},
|
||||
});
|
||||
const inputSchema = tool.inputSchema as typeof schema;
|
||||
const result = inputSchema.safeParse({ userName: 'Alice', location: 'Berlin' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// Tags are stripped from response regardless
|
||||
expect(result.cleanText).toBe('Response text.');
|
||||
// Raw content is returned — caller decides whether it passes validation
|
||||
expect(result.workingMemory).toBe(wrongShape);
|
||||
it('handler serializes input to JSON and calls persist', async () => {
|
||||
const persisted: string[] = [];
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: true,
|
||||
schema,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
persist: async (content) => {
|
||||
persisted.push(content);
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the content would indeed fail schema validation
|
||||
expect(result.workingMemory).not.toBeNull();
|
||||
const input = { userName: 'Alice', location: 'Berlin' };
|
||||
await tool.handler!(input, {} as never);
|
||||
|
||||
expect(persisted).toHaveLength(1);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.workingMemory!);
|
||||
parsed = JSON.parse(persisted[0]) as unknown;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed).toMatchObject(input);
|
||||
});
|
||||
|
||||
it('strips tags even when content is completely non-JSON', () => {
|
||||
const text =
|
||||
'My reply.\n<working_memory>\nthis is just plain text, not JSON at all\n</working_memory>';
|
||||
const result = parseWorkingMemory(text);
|
||||
it('handler returns success confirmation', async () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: true,
|
||||
schema,
|
||||
persist: async () => {},
|
||||
});
|
||||
const result = await tool.handler!({ userName: 'Alice' }, {} as never);
|
||||
expect(result).toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
expect(result.cleanText).toBe('My reply.');
|
||||
expect(result.workingMemory).toBe('this is just plain text, not JSON at all');
|
||||
it('falls back to freeform when no schema provided despite structured:true', () => {
|
||||
const tool = buildWorkingMemoryTool({
|
||||
structured: true,
|
||||
persist: async () => {},
|
||||
});
|
||||
const inputSchema = tool.inputSchema as z.ZodObject<z.ZodRawShape>;
|
||||
const result = inputSchema.safeParse({ memory: 'fallback text' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,10 +106,17 @@ export type {
|
|||
ModelLimits,
|
||||
} from './sdk/catalog';
|
||||
export { SqliteMemory } from './storage/sqlite-memory';
|
||||
export {
|
||||
UPDATE_WORKING_MEMORY_TOOL_NAME,
|
||||
WORKING_MEMORY_DEFAULT_INSTRUCTION,
|
||||
} from './runtime/working-memory';
|
||||
export type { SqliteMemoryConfig } from './storage/sqlite-memory';
|
||||
export { PostgresMemory } from './storage/postgres-memory';
|
||||
export type { PostgresMemoryConfig } from './storage/postgres-memory';
|
||||
|
||||
export { createModel } from './runtime/model-factory';
|
||||
export { generateTitleFromMessage } from './runtime/title-generation';
|
||||
|
||||
export { Workspace } from './workspace';
|
||||
export { BaseFilesystem } from './workspace';
|
||||
export { BaseSandbox } from './workspace';
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import type {
|
|||
XaiThinkingConfig,
|
||||
} from '../types';
|
||||
import { AgentEventBus } from './event-bus';
|
||||
import { createFilteredLogger } from './logger';
|
||||
import { saveMessagesToThread } from './memory-store';
|
||||
import { AgentMessageList, type SerializedMessageList } from './message-list';
|
||||
import { fromAiFinishReason, fromAiMessages } from './messages';
|
||||
|
|
@ -57,7 +56,7 @@ import {
|
|||
toAiSdkProviderTools,
|
||||
toAiSdkTools,
|
||||
} from './tool-adapter';
|
||||
import { parseWorkingMemory, WorkingMemoryStreamFilter } from './working-memory';
|
||||
import { buildWorkingMemoryTool } from './working-memory';
|
||||
import { AgentEvent } from '../types/runtime/event';
|
||||
import type {
|
||||
AgentPersistenceOptions,
|
||||
|
|
@ -75,19 +74,6 @@ import type {
|
|||
import type { JSONObject, JSONValue } from '../types/utils/json';
|
||||
import { isZodSchema } from '../utils/zod';
|
||||
|
||||
const logger = createFilteredLogger();
|
||||
|
||||
/** Type guard for text content parts in LLM messages. */
|
||||
function isTextPart(part: unknown): part is { type: 'text'; text: string } {
|
||||
return (
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
'type' in part &&
|
||||
(part as Record<string, unknown>).type === 'text' &&
|
||||
'text' in part
|
||||
);
|
||||
}
|
||||
|
||||
export interface AgentRuntimeConfig {
|
||||
name: string;
|
||||
model: ModelConfig;
|
||||
|
|
@ -102,6 +88,7 @@ export interface AgentRuntimeConfig {
|
|||
structured: boolean;
|
||||
schema?: z.ZodObject<z.ZodRawShape>;
|
||||
scope?: 'resource' | 'thread';
|
||||
instruction?: string;
|
||||
};
|
||||
semanticRecall?: SemanticRecallConfig;
|
||||
structuredOutput?: z.ZodType;
|
||||
|
|
@ -628,7 +615,7 @@ export class AgentRuntime {
|
|||
runId?: string,
|
||||
): Promise<GenerateResult> {
|
||||
const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } =
|
||||
this.buildLoopContext(options);
|
||||
this.buildLoopContext({ ...options, persistence: options?.persistence });
|
||||
|
||||
let totalUsage: TokenUsage | undefined;
|
||||
let lastFinishReason: FinishReason = 'stop';
|
||||
|
|
@ -760,19 +747,6 @@ export class AgentRuntime {
|
|||
);
|
||||
}
|
||||
|
||||
// Extract and strip working memory from assistant response
|
||||
if (
|
||||
this.config.workingMemory &&
|
||||
this.config.memory?.saveWorkingMemory &&
|
||||
options?.persistence
|
||||
) {
|
||||
this.extractAndPersistWorkingMemory(list, {
|
||||
threadId: options.persistence.threadId,
|
||||
resourceId: options.persistence.resourceId,
|
||||
scope: this.config.workingMemory?.scope ?? 'resource',
|
||||
});
|
||||
}
|
||||
|
||||
await this.saveToMemory(list, options);
|
||||
await this.flushTelemetry(options);
|
||||
|
||||
|
|
@ -850,22 +824,10 @@ export class AgentRuntime {
|
|||
runId?: string,
|
||||
): Promise<void> {
|
||||
const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } =
|
||||
this.buildLoopContext(options);
|
||||
|
||||
// Wrap writer with working memory filter if configured
|
||||
const wmParamsStream = this.resolveWorkingMemoryParams(options?.persistence);
|
||||
const wmFilter = wmParamsStream?.persistFn
|
||||
? new WorkingMemoryStreamFilter(writer, async (content: string) => {
|
||||
await wmParamsStream.persistFn(content);
|
||||
})
|
||||
: undefined;
|
||||
this.buildLoopContext({ ...options, persistence: options?.persistence });
|
||||
|
||||
const writeChunk = async (chunk: StreamChunk): Promise<void> => {
|
||||
if (wmFilter) {
|
||||
await wmFilter.write(chunk);
|
||||
} else {
|
||||
await writer.write(chunk);
|
||||
}
|
||||
await writer.write(chunk);
|
||||
};
|
||||
|
||||
let totalUsage: TokenUsage | undefined;
|
||||
|
|
@ -877,7 +839,6 @@ export class AgentRuntime {
|
|||
const closeStreamWithError = async (error: unknown, status: AgentRunState): Promise<void> => {
|
||||
await this.cleanupRun(runId);
|
||||
this.updateState({ status });
|
||||
if (wmFilter) await wmFilter.flush();
|
||||
await writer.write({ type: 'error', error });
|
||||
await writer.write({ type: 'finish', finishReason: 'error' });
|
||||
await writer.close();
|
||||
|
|
@ -1065,8 +1026,6 @@ export class AgentRuntime {
|
|||
this.emitTurnEnd(newMessages, extractToolResults(list.responseDelta()));
|
||||
}
|
||||
|
||||
if (wmFilter) await wmFilter.flush();
|
||||
|
||||
const costUsage = this.applyCost(totalUsage);
|
||||
const parentCost = costUsage?.cost ?? 0;
|
||||
const subCost = collectedSubAgentUsage.reduce((sum, s) => sum + (s.usage.cost ?? 0), 0);
|
||||
|
|
@ -1083,19 +1042,6 @@ export class AgentRuntime {
|
|||
});
|
||||
|
||||
try {
|
||||
// Extract and strip working memory from assistant response
|
||||
if (
|
||||
this.config.workingMemory &&
|
||||
this.config.memory?.saveWorkingMemory &&
|
||||
options?.persistence
|
||||
) {
|
||||
this.extractAndPersistWorkingMemory(list, {
|
||||
threadId: options.persistence.threadId,
|
||||
resourceId: options.persistence.resourceId,
|
||||
scope: this.config.workingMemory?.scope ?? 'resource',
|
||||
});
|
||||
}
|
||||
|
||||
await this.saveToMemory(list, options);
|
||||
|
||||
if (this.config.titleGeneration && options?.persistence && this.config.memory) {
|
||||
|
|
@ -1187,43 +1133,6 @@ export class AgentRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract <working_memory> tags from the last assistant message in the turn delta,
|
||||
* strip them from the message, and persist the working memory content.
|
||||
*/
|
||||
private extractAndPersistWorkingMemory(
|
||||
list: AgentMessageList,
|
||||
params: { threadId: string; resourceId: string; scope: 'resource' | 'thread' },
|
||||
): void {
|
||||
const delta = list.responseDelta();
|
||||
for (let i = delta.length - 1; i >= 0; i--) {
|
||||
const msg = delta[i];
|
||||
if (!isLlmMessage(msg) || msg.role !== 'assistant') continue;
|
||||
for (const part of msg.content) {
|
||||
if (!isTextPart(part)) continue;
|
||||
const { cleanText, workingMemory } = parseWorkingMemory(part.text);
|
||||
if (workingMemory !== null) {
|
||||
// Validate structured working memory if schema is configured
|
||||
if (this.config.workingMemory?.structured && this.config.workingMemory.schema) {
|
||||
try {
|
||||
this.config.workingMemory.schema.parse(JSON.parse(workingMemory));
|
||||
} catch {
|
||||
// Validation failed — keep previous state, still strip tags
|
||||
part.text = cleanText;
|
||||
return;
|
||||
}
|
||||
}
|
||||
part.text = cleanText;
|
||||
// Fire-and-forget persist
|
||||
this.config.memory!.saveWorkingMemory!(params, workingMemory).catch((error: unknown) => {
|
||||
logger.warn('Failed to persist working memory', { error });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the providerOptions object for thinking/reasoning config. */
|
||||
private buildThinkingProviderOptions(): Record<string, Record<string, unknown>> | undefined {
|
||||
if (!this.config.thinking) return undefined;
|
||||
|
|
@ -1691,13 +1600,19 @@ export class AgentRuntime {
|
|||
}
|
||||
|
||||
/** Build common LLM call dependencies shared by both the generate and stream loops. */
|
||||
private buildLoopContext(execOptions?: ExecutionOptions) {
|
||||
const aiTools = toAiSdkTools(this.config.tools);
|
||||
private buildLoopContext(
|
||||
execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions },
|
||||
) {
|
||||
const wmTool = this.buildWorkingMemoryToolForRun(execOptions?.persistence);
|
||||
const allUserTools = wmTool
|
||||
? [...(this.config.tools ?? []), wmTool]
|
||||
: (this.config.tools ?? []);
|
||||
const aiTools = toAiSdkTools(allUserTools);
|
||||
const aiProviderTools = toAiSdkProviderTools(this.config.providerTools);
|
||||
const allTools = { ...aiTools, ...aiProviderTools };
|
||||
return {
|
||||
model: createModel(this.config.model),
|
||||
toolMap: buildToolMap(this.config.tools),
|
||||
toolMap: buildToolMap(allUserTools),
|
||||
aiTools: allTools,
|
||||
providerOptions: this.buildCallProviderOptions(execOptions?.providerOptions),
|
||||
hasTools: Object.keys(allTools).length > 0,
|
||||
|
|
@ -1707,6 +1622,20 @@ export class AgentRuntime {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the updateWorkingMemory BuiltTool for the current run.
|
||||
* Returns undefined when working memory is not configured or persistence is unavailable.
|
||||
*/
|
||||
private buildWorkingMemoryToolForRun(persistence: AgentPersistenceOptions | undefined) {
|
||||
const wmParams = this.resolveWorkingMemoryParams(persistence);
|
||||
if (!wmParams) return undefined;
|
||||
return buildWorkingMemoryTool({
|
||||
structured: wmParams.structured,
|
||||
schema: wmParams.schema,
|
||||
persist: wmParams.persistFn,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a suspended run state and update the current state snapshot.
|
||||
* Returns the runId (reuses existingRunId when resuming to prevent dangling runs).
|
||||
|
|
@ -1804,6 +1733,7 @@ export class AgentRuntime {
|
|||
template: wmParams.template,
|
||||
structured: wmParams.structured,
|
||||
state: wmState,
|
||||
...(wmParams.instruction !== undefined && { instruction: wmParams.instruction }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1832,6 +1762,7 @@ export class AgentRuntime {
|
|||
template: this.config.workingMemory.template,
|
||||
structured: this.config.workingMemory.structured,
|
||||
schema: this.config.workingMemory.schema,
|
||||
instruction: this.config.workingMemory.instruction,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface WorkingMemoryContext {
|
|||
structured: boolean;
|
||||
/** The current persisted state, or null if not yet loaded. Falls back to template. */
|
||||
state: string | null;
|
||||
/** Custom instruction text. When absent the default instruction is used. */
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,10 +146,11 @@ export class AgentMessageList {
|
|||
const wmInstruction = buildWorkingMemoryInstruction(
|
||||
this.workingMemory.template,
|
||||
this.workingMemory.structured,
|
||||
this.workingMemory.instruction,
|
||||
);
|
||||
const wmState = this.workingMemory.state ?? this.workingMemory.template;
|
||||
systemPrompt +=
|
||||
wmInstruction + '\n\nCurrent working memory state:\n```\n' + wmState + '\n```';
|
||||
wmInstruction + '\n\nCurrent working memory state:\n```\n' + wmState + '\n```\n';
|
||||
}
|
||||
|
||||
const systemMessage: ModelMessage = instructionProviderOptions
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import type { EmbeddingModel, LanguageModel } from 'ai';
|
||||
import type * as Undici from 'undici';
|
||||
|
||||
import type { ModelConfig } from '../types/sdk/agent';
|
||||
|
||||
type FetchFn = typeof globalThis.fetch;
|
||||
type CreateProviderFn = (opts?: {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
fetch?: FetchFn;
|
||||
headers?: Record<string, string>;
|
||||
}) => (model: string) => LanguageModel;
|
||||
type CreateEmbeddingProviderFn = (opts?: { apiKey?: string }) => {
|
||||
embeddingModel(model: string): EmbeddingModel;
|
||||
|
|
@ -15,6 +19,26 @@ function isLanguageModel(config: unknown): config is LanguageModel {
|
|||
return typeof config === 'object' && config !== null && 'doGenerate' in config;
|
||||
}
|
||||
|
||||
/**
|
||||
* When HTTP_PROXY / HTTPS_PROXY is set (e.g. in e2e tests with MockServer),
|
||||
* return a fetch function that routes requests through the proxy. The default
|
||||
* globalThis.fetch in Node ≥18 does NOT respect these env vars, so AI SDK
|
||||
* providers would bypass the proxy without this.
|
||||
*/
|
||||
function getProxyFetch(): FetchFn | undefined {
|
||||
const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
|
||||
if (!proxyUrl) return undefined;
|
||||
|
||||
const { ProxyAgent } = require('undici') as typeof Undici;
|
||||
const dispatcher = new ProxyAgent(proxyUrl);
|
||||
return (async (url, init) =>
|
||||
await globalThis.fetch(url, {
|
||||
...init,
|
||||
// @ts-expect-error dispatcher is a valid undici option for Node.js fetch
|
||||
dispatcher,
|
||||
})) as FetchFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider packages are loaded dynamically via require() so only the
|
||||
* provider needed at runtime must be installed.
|
||||
|
|
@ -33,6 +57,7 @@ export function createModel(config: ModelConfig): LanguageModel {
|
|||
const modelId = stripEmpty(typeof config === 'string' ? config : config.id);
|
||||
const apiKey = stripEmpty(typeof config === 'string' ? undefined : config.apiKey);
|
||||
const baseURL = stripEmpty(typeof config === 'string' ? undefined : config.url);
|
||||
const headers = typeof config === 'string' ? undefined : config.headers;
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error('Model ID is required');
|
||||
|
|
@ -40,31 +65,32 @@ export function createModel(config: ModelConfig): LanguageModel {
|
|||
|
||||
const [provider, ...rest] = modelId.split('/');
|
||||
const modelName = rest.join('/');
|
||||
const fetch = getProxyFetch();
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic': {
|
||||
const { createAnthropic } = require('@ai-sdk/anthropic') as {
|
||||
createAnthropic: CreateProviderFn;
|
||||
};
|
||||
return createAnthropic({ apiKey, baseURL })(modelName);
|
||||
return createAnthropic({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'openai': {
|
||||
const { createOpenAI } = require('@ai-sdk/openai') as {
|
||||
createOpenAI: CreateProviderFn;
|
||||
};
|
||||
return createOpenAI({ apiKey, baseURL })(modelName);
|
||||
return createOpenAI({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'google': {
|
||||
const { createGoogleGenerativeAI } = require('@ai-sdk/google') as {
|
||||
createGoogleGenerativeAI: CreateProviderFn;
|
||||
};
|
||||
return createGoogleGenerativeAI({ apiKey, baseURL })(modelName);
|
||||
return createGoogleGenerativeAI({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
case 'xai': {
|
||||
const { createXai } = require('@ai-sdk/xai') as {
|
||||
createXai: CreateProviderFn;
|
||||
};
|
||||
return createXai({ apiKey, baseURL })(modelName);
|
||||
return createXai({ apiKey, baseURL, fetch, headers })(modelName);
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { generateText } from 'ai';
|
||||
import { generateText, type LanguageModel } from 'ai';
|
||||
|
||||
import type { BuiltMemory, TitleGenerationConfig } from '../types';
|
||||
import { createFilteredLogger } from './logger';
|
||||
|
|
@ -10,13 +10,83 @@ const logger = createFilteredLogger();
|
|||
|
||||
const DEFAULT_TITLE_INSTRUCTIONS = [
|
||||
'- you will generate a short title based on the first message a user begins a conversation with',
|
||||
"- the title should be a summary of the user's message",
|
||||
'- the title should describe what the user asked for, not what an assistant might reply',
|
||||
'- 1 to 5 words, no more than 80 characters',
|
||||
'- use sentence case (e.g. "Conversation title" instead of "Conversation Title")',
|
||||
'- do not use quotes, colons, or markdown formatting',
|
||||
'- the entire text you return will be used directly as the title, so respond with the title only',
|
||||
].join('\n');
|
||||
|
||||
const TRIVIAL_MESSAGE_MAX_CHARS = 15;
|
||||
const TRIVIAL_MESSAGE_MAX_WORDS = 3;
|
||||
const MAX_TITLE_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* Whether a user message has too little substance to title a conversation
|
||||
* (e.g. "hey", "hello"). For these, the LLM tends to hallucinate an
|
||||
* assistant-voice reply as the title — better to signal "defer, not enough
|
||||
* signal yet" so the caller can retry once more context accumulates.
|
||||
*/
|
||||
function isTrivialMessage(message: string): boolean {
|
||||
const normalized = message.trim();
|
||||
if (normalized.length <= TRIVIAL_MESSAGE_MAX_CHARS) return true;
|
||||
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
return wordCount <= TRIVIAL_MESSAGE_MAX_WORDS;
|
||||
}
|
||||
|
||||
function sanitizeTitle(raw: string): string {
|
||||
// Strip <think>...</think> blocks (e.g. from DeepSeek R1)
|
||||
let title = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
// Strip markdown heading prefixes and inline emphasis markers
|
||||
title = title
|
||||
.replace(/^#{1,6}\s+/, '')
|
||||
.replace(/\*+/g, '')
|
||||
.trim();
|
||||
// Strip surrounding quotes
|
||||
title = title.replace(/^["']|["']$/g, '').trim();
|
||||
if (title.length > MAX_TITLE_LENGTH) {
|
||||
const truncated = title.slice(0, MAX_TITLE_LENGTH);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
title = (lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated) + '\u2026';
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sanitized thread title from a user message using an LLM.
|
||||
*
|
||||
* Returns `null` on empty input or empty LLM output. For trivial messages
|
||||
* (e.g. greetings), returns the sanitized message itself without calling
|
||||
* the LLM — this avoids the failure mode where the model responds with
|
||||
* an assistant-voice reply as the title.
|
||||
*/
|
||||
export async function generateTitleFromMessage(
|
||||
model: LanguageModel,
|
||||
userMessage: string,
|
||||
opts?: { instructions?: string },
|
||||
): Promise<string | null> {
|
||||
const trimmed = userMessage.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (isTrivialMessage(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: opts?.instructions ?? DEFAULT_TITLE_INSTRUCTIONS },
|
||||
{ role: 'user', content: trimmed },
|
||||
],
|
||||
});
|
||||
|
||||
const raw = result.text?.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const title = sanitizeTitle(raw);
|
||||
return title || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title for a thread if it doesn't already have one.
|
||||
*
|
||||
|
|
@ -49,28 +119,9 @@ export async function generateThreadTitle(opts: {
|
|||
|
||||
const titleModelId = opts.titleConfig.model ?? opts.agentModel;
|
||||
const titleModel = createModel(titleModelId);
|
||||
const instructions = opts.titleConfig.instructions ?? DEFAULT_TITLE_INSTRUCTIONS;
|
||||
|
||||
const result = await generateText({
|
||||
model: titleModel,
|
||||
messages: [
|
||||
{ role: 'system', content: instructions },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
const title = await generateTitleFromMessage(titleModel, userText, {
|
||||
instructions: opts.titleConfig.instructions,
|
||||
});
|
||||
|
||||
let title = result.text?.trim();
|
||||
if (!title) return;
|
||||
|
||||
// Strip <think>...</think> blocks (e.g. from DeepSeek R1)
|
||||
title = title.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
if (!title) return;
|
||||
|
||||
// Strip markdown heading prefixes and inline formatting
|
||||
title = title
|
||||
.replace(/^#{1,6}\s+/, '')
|
||||
.replace(/\*+/g, '')
|
||||
.trim();
|
||||
if (!title) return;
|
||||
|
||||
await opts.memory.saveThread({
|
||||
|
|
|
|||
|
|
@ -1,58 +1,48 @@
|
|||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { StreamChunk } from '../types';
|
||||
import { createFilteredLogger } from './logger';
|
||||
|
||||
const logger = createFilteredLogger();
|
||||
import type { BuiltTool } from '../types';
|
||||
|
||||
type ZodObjectSchema = z.ZodObject<z.ZodRawShape>;
|
||||
|
||||
const OPEN_TAG = '<working_memory>';
|
||||
const CLOSE_TAG = '</working_memory>';
|
||||
export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'updateWorkingMemory';
|
||||
|
||||
/**
|
||||
* Extract working memory content from an LLM response.
|
||||
* Returns the clean text (tags stripped) and the extracted working memory (or null).
|
||||
* The default instruction block injected into the system prompt when working memory
|
||||
* is configured. Exported so callers can reference it when building custom instructions.
|
||||
*/
|
||||
export function parseWorkingMemory(text: string): {
|
||||
cleanText: string;
|
||||
workingMemory: string | null;
|
||||
} {
|
||||
const openIdx = text.indexOf(OPEN_TAG);
|
||||
if (openIdx === -1) return { cleanText: text, workingMemory: null };
|
||||
|
||||
const closeIdx = text.indexOf(CLOSE_TAG, openIdx);
|
||||
if (closeIdx === -1) return { cleanText: text, workingMemory: null };
|
||||
|
||||
const contentStart = openIdx + OPEN_TAG.length;
|
||||
const rawContent = text.slice(contentStart, closeIdx);
|
||||
const workingMemory = rawContent.replace(/^\n/, '').replace(/\n$/, '');
|
||||
|
||||
const before = text.slice(0, openIdx).replace(/\n$/, '');
|
||||
const after = text.slice(closeIdx + CLOSE_TAG.length).replace(/^\n/, '');
|
||||
const cleanText = (before + (after ? '\n' + after : '')).trim();
|
||||
|
||||
return { cleanText, workingMemory };
|
||||
}
|
||||
export const WORKING_MEMORY_DEFAULT_INSTRUCTION = [
|
||||
'You have persistent working memory that survives across conversations.',
|
||||
'Your current working memory state is shown below.',
|
||||
`When you learn new information about the user or conversation that should be remembered, call the \`${UPDATE_WORKING_MEMORY_TOOL_NAME}\` tool.`,
|
||||
'Only call it when something has actually changed — do NOT call it if nothing new was learned.',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Generate the system prompt instruction for working memory.
|
||||
* Tells the LLM to call the updateWorkingMemory tool when it has new information to persist.
|
||||
*
|
||||
* @param template - The working memory template or schema.
|
||||
* @param structured - Whether the working memory is structured (JSON schema).
|
||||
* @param instruction - Custom instruction text to replace the default. Defaults to
|
||||
* {@link WORKING_MEMORY_DEFAULT_INSTRUCTION}.
|
||||
*/
|
||||
export function buildWorkingMemoryInstruction(template: string, structured: boolean): string {
|
||||
export function buildWorkingMemoryInstruction(
|
||||
template: string,
|
||||
structured: boolean,
|
||||
instruction?: string,
|
||||
): string {
|
||||
const format = structured
|
||||
? 'Emit the updated state as valid JSON matching the schema'
|
||||
? 'The memory argument must be valid JSON matching the schema'
|
||||
: 'Update the template with any new information learned';
|
||||
|
||||
const body = instruction ?? WORKING_MEMORY_DEFAULT_INSTRUCTION;
|
||||
|
||||
return [
|
||||
'',
|
||||
'## Working Memory',
|
||||
'',
|
||||
'You have persistent working memory that survives across conversations.',
|
||||
'The current state will be shown to you in a system message.',
|
||||
'IMPORTANT: Always respond to the user first with your normal reply.',
|
||||
`Then, at the very end of your response, emit your updated working memory inside ${OPEN_TAG}...${CLOSE_TAG} tags on a new line.`,
|
||||
`${format}. If nothing changed, emit the current state unchanged.`,
|
||||
'The working memory block must be the last thing in your response, after your reply to the user.',
|
||||
body,
|
||||
`${format}.`,
|
||||
'',
|
||||
'Current template:',
|
||||
'```',
|
||||
|
|
@ -73,111 +63,51 @@ export function templateFromSchema(schema: ZodObjectSchema): string {
|
|||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
type PersistFn = (content: string) => Promise<void>;
|
||||
export interface WorkingMemoryToolConfig {
|
||||
/** Whether this is structured (Zod-schema-driven) working memory. */
|
||||
structured: boolean;
|
||||
/** Zod schema for structured working memory input validation. */
|
||||
schema?: ZodObjectSchema;
|
||||
/** Called with the serialized working memory string to persist it. */
|
||||
persist: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a stream writer to intercept <working_memory> tags from text-delta chunks.
|
||||
* All non-text-delta chunks pass through unchanged.
|
||||
* Text inside the tags is buffered and persisted when the closing tag is detected.
|
||||
* Build the updateWorkingMemory BuiltTool that the agent calls to persist working memory.
|
||||
*
|
||||
* For freeform working memory the input schema is `{ memory: string }`.
|
||||
* For structured working memory the input schema is the configured Zod object schema,
|
||||
* whose values are serialized to JSON before persisting.
|
||||
*/
|
||||
export class WorkingMemoryStreamFilter {
|
||||
private writer: WritableStreamDefaultWriter<StreamChunk>;
|
||||
|
||||
private persist: PersistFn;
|
||||
|
||||
private state: 'normal' | 'inside' = 'normal';
|
||||
|
||||
private buffer = '';
|
||||
|
||||
private pendingText = '';
|
||||
|
||||
constructor(writer: WritableStreamDefaultWriter<StreamChunk>, persist: PersistFn) {
|
||||
this.writer = writer;
|
||||
this.persist = persist;
|
||||
export function buildWorkingMemoryTool(config: WorkingMemoryToolConfig): BuiltTool {
|
||||
if (config.structured && config.schema) {
|
||||
const schema = config.schema;
|
||||
return {
|
||||
name: UPDATE_WORKING_MEMORY_TOOL_NAME,
|
||||
description:
|
||||
'Update your persistent working memory with new information about the user or conversation. Only call this when something has actually changed.',
|
||||
inputSchema: schema,
|
||||
handler: async (input: unknown) => {
|
||||
const content = JSON.stringify(input, null, 2);
|
||||
await config.persist(content);
|
||||
return { success: true, message: 'Working memory updated.' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async write(chunk: StreamChunk): Promise<void> {
|
||||
if (chunk.type !== 'text-delta') {
|
||||
await this.writer.write(chunk);
|
||||
return;
|
||||
}
|
||||
const freeformSchema = z.object({
|
||||
memory: z.string().describe('The updated working memory content.'),
|
||||
});
|
||||
|
||||
this.pendingText += chunk.delta;
|
||||
|
||||
while (this.pendingText.length > 0) {
|
||||
if (this.state === 'normal') {
|
||||
const openIdx = this.pendingText.indexOf(OPEN_TAG);
|
||||
if (openIdx === -1) {
|
||||
// No full open tag found. Check if the tail is a valid prefix of OPEN_TAG.
|
||||
const lastLt = this.pendingText.lastIndexOf('<');
|
||||
if (
|
||||
lastLt !== -1 &&
|
||||
this.pendingText.length - lastLt < OPEN_TAG.length &&
|
||||
OPEN_TAG.startsWith(this.pendingText.slice(lastLt))
|
||||
) {
|
||||
// Potential partial tag at end — forward everything before it, hold the rest
|
||||
if (lastLt > 0) {
|
||||
await this.writer.write({
|
||||
type: 'text-delta',
|
||||
delta: this.pendingText.slice(0, lastLt),
|
||||
});
|
||||
}
|
||||
this.pendingText = this.pendingText.slice(lastLt);
|
||||
} else {
|
||||
// No partial tag concern — forward everything
|
||||
await this.writer.write({ type: 'text-delta', delta: this.pendingText });
|
||||
this.pendingText = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Forward text before the tag
|
||||
if (openIdx > 0) {
|
||||
await this.writer.write({
|
||||
type: 'text-delta',
|
||||
delta: this.pendingText.slice(0, openIdx),
|
||||
});
|
||||
}
|
||||
this.state = 'inside';
|
||||
this.pendingText = this.pendingText.slice(openIdx + OPEN_TAG.length);
|
||||
this.buffer = '';
|
||||
} else {
|
||||
// Inside tag — look for closing tag
|
||||
const closeIdx = this.pendingText.indexOf(CLOSE_TAG);
|
||||
if (closeIdx === -1) {
|
||||
// Check if the tail is a valid prefix of CLOSE_TAG — hold it back
|
||||
const lastLt = this.pendingText.lastIndexOf('<');
|
||||
if (
|
||||
lastLt !== -1 &&
|
||||
this.pendingText.length - lastLt < CLOSE_TAG.length &&
|
||||
CLOSE_TAG.startsWith(this.pendingText.slice(lastLt))
|
||||
) {
|
||||
this.buffer += this.pendingText.slice(0, lastLt);
|
||||
this.pendingText = this.pendingText.slice(lastLt);
|
||||
} else {
|
||||
this.buffer += this.pendingText;
|
||||
this.pendingText = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.buffer += this.pendingText.slice(0, closeIdx);
|
||||
this.pendingText = this.pendingText.slice(closeIdx + CLOSE_TAG.length);
|
||||
this.state = 'normal';
|
||||
const content = this.buffer.replace(/^\n/, '').replace(/\n$/, '');
|
||||
this.persist(content).catch((error: unknown) => {
|
||||
logger.warn('Failed to persist working memory', { error });
|
||||
});
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.state === 'normal' && this.pendingText.length > 0) {
|
||||
await this.writer.write({ type: 'text-delta', delta: this.pendingText });
|
||||
}
|
||||
// Reset all state so the filter is clean for reuse after abort/completion.
|
||||
this.pendingText = '';
|
||||
this.buffer = '';
|
||||
this.state = 'normal';
|
||||
}
|
||||
return {
|
||||
name: UPDATE_WORKING_MEMORY_TOOL_NAME,
|
||||
description:
|
||||
'Update your persistent working memory with new information about the user or conversation. Only call this when something has actually changed.',
|
||||
inputSchema: freeformSchema,
|
||||
handler: async (input: unknown) => {
|
||||
const { memory } = input as z.infer<typeof freeformSchema>;
|
||||
await config.persist(memory);
|
||||
return { success: true, message: 'Working memory updated.' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export class Memory {
|
|||
|
||||
private workingMemoryScope: 'resource' | 'thread' = 'resource';
|
||||
|
||||
private workingMemoryInstruction?: string;
|
||||
|
||||
private memoryBackend?: BuiltMemory;
|
||||
|
||||
private titleGenerationConfig?: TitleGenerationConfig;
|
||||
|
|
@ -102,6 +104,26 @@ export class Memory {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default instruction text injected into the system prompt for working memory.
|
||||
*
|
||||
* The instruction tells the model when and how to call the `updateWorkingMemory` tool.
|
||||
* When omitted, `WORKING_MEMORY_DEFAULT_INSTRUCTION` is used.
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* import { WORKING_MEMORY_DEFAULT_INSTRUCTION } from '@n8n/agents';
|
||||
*
|
||||
* memory.instruction(
|
||||
* WORKING_MEMORY_DEFAULT_INSTRUCTION + '\nAlways update after every user message.',
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
instruction(text: string): this {
|
||||
this.workingMemoryInstruction = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable automatic title generation for new threads.
|
||||
*
|
||||
|
|
@ -167,12 +189,18 @@ export class Memory {
|
|||
structured: true,
|
||||
schema: this.workingMemorySchema,
|
||||
scope: this.workingMemoryScope,
|
||||
...(this.workingMemoryInstruction !== undefined && {
|
||||
instruction: this.workingMemoryInstruction,
|
||||
}),
|
||||
};
|
||||
} else if (this.workingMemoryTemplate !== undefined) {
|
||||
workingMemory = {
|
||||
template: this.workingMemoryTemplate,
|
||||
structured: false,
|
||||
scope: this.workingMemoryScope,
|
||||
...(this.workingMemoryInstruction !== undefined && {
|
||||
instruction: this.workingMemoryInstruction,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ export type TokenUsage<T extends Record<string, unknown> = Record<string, unknow
|
|||
additionalMetadata?: T;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string
|
||||
export type ModelConfig = string | { id: string; apiKey?: string; url?: string } | LanguageModel;
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string */
|
||||
export type ModelConfig =
|
||||
| string
|
||||
| { id: string; apiKey?: string; url?: string; headers?: Record<string, string> }
|
||||
| LanguageModel;
|
||||
/* eslint-enable @typescript-eslint/no-redundant-type-constituents */
|
||||
|
||||
export interface AgentResult {
|
||||
id?: string;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ export interface MemoryConfig {
|
|||
structured: boolean;
|
||||
schema?: z.ZodObject<z.ZodRawShape>;
|
||||
scope: 'resource' | 'thread';
|
||||
/**
|
||||
* Custom instruction text injected into the system prompt in place of the default.
|
||||
* When omitted the runtime uses {@link WORKING_MEMORY_DEFAULT_INSTRUCTION}.
|
||||
*/
|
||||
instruction?: string;
|
||||
};
|
||||
semanticRecall?: SemanticRecallConfig;
|
||||
titleGeneration?: TitleGenerationConfig;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { DETERMINISTIC_CHECKS } from '../checks';
|
||||
import { createBinaryChecksEvaluator } from '../index';
|
||||
|
||||
const mockNodeTypes: INodeTypeDescription[] = [];
|
||||
|
|
@ -14,7 +15,7 @@ describe('createBinaryChecksEvaluator', () => {
|
|||
const evaluator = createBinaryChecksEvaluator({ nodeTypes: mockNodeTypes });
|
||||
const workflow = { name: 'test', nodes: [], connections: {} };
|
||||
const feedback = await evaluator.evaluate(workflow, { prompt: 'test' });
|
||||
expect(feedback.length).toBe(17); // 17 deterministic checks
|
||||
expect(feedback.length).toBe(DETERMINISTIC_CHECKS.length);
|
||||
expect(feedback.every((f) => f.evaluator === 'binary-checks')).toBe(true);
|
||||
expect(feedback.every((f) => f.kind === 'metric')).toBe(true);
|
||||
expect(feedback.every((f) => f.score === 0 || f.score === 1)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { BinaryCheckContext } from '../../types';
|
|||
import { allNodesConnected } from '../all-nodes-connected';
|
||||
import { expressionsReferenceExistingNodes } from '../expressions-reference-existing-nodes';
|
||||
import { hasStartNode } from '../has-start-node';
|
||||
import { noCodeImports } from '../no-code-imports';
|
||||
import { noEmptySetNodes } from '../no-empty-set-nodes';
|
||||
import { noUnnecessaryCodeNodes } from '../no-unnecessary-code-nodes';
|
||||
import { noUnreachableNodes } from '../no-unreachable-nodes';
|
||||
|
|
@ -1045,3 +1046,248 @@ describe('tools_have_parameters', () => {
|
|||
expect(result.pass).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no_code_imports', () => {
|
||||
it('passes when no code nodes', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [{ name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3, position: [0, 0] }],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when code node has no imports', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return items.map(item => ({ json: { value: item.json.value * 2 } }));',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when JS code uses require()', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'javaScript',
|
||||
jsCode: "const lodash = require('lodash');\nreturn items;",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Code');
|
||||
});
|
||||
|
||||
it('fails when JS code uses import from', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'javaScript',
|
||||
jsCode: "import axios from 'axios';\nreturn items;",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Code');
|
||||
});
|
||||
|
||||
it('fails when JS code uses dynamic import()', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'javaScript',
|
||||
jsCode: "const mod = await import('some-module');\nreturn items;",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Code');
|
||||
});
|
||||
|
||||
it('fails when Python code uses import statement', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'PyCode',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'import requests\nreturn items',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('PyCode');
|
||||
});
|
||||
|
||||
it('fails when Python code uses from...import', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'PyCode',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'from os import path\nreturn items',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('PyCode');
|
||||
});
|
||||
|
||||
it('fails when Python code uses __import__()', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'PyCode',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'pythonNative',
|
||||
pythonCode: "os = __import__('os')\nreturn items",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('PyCode');
|
||||
});
|
||||
|
||||
it('passes when Python code has no imports', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'PyCode',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'return [{"json": {"value": 42}}]',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to javaScript when language is not set', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
jsCode: "const fs = require('fs');\nreturn items;",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Code');
|
||||
});
|
||||
|
||||
it('reports all code nodes with imports', async () => {
|
||||
const result = await noCodeImports.run(
|
||||
makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
name: 'Code1',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
language: 'javaScript',
|
||||
jsCode: "const _ = require('lodash');\nreturn items;",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Code2',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'import json\nreturn items',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain('Code1');
|
||||
expect(result.comment).toContain('Code2');
|
||||
});
|
||||
|
||||
it('passes for empty workflow', async () => {
|
||||
const result = await noCodeImports.run(makeWorkflow({ nodes: [] }), makeCtx());
|
||||
expect(result.pass).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { BinaryCheck } from '../types';
|
|||
import { allNodesConnected } from './all-nodes-connected';
|
||||
import { expressionsReferenceExistingNodes } from './expressions-reference-existing-nodes';
|
||||
import { hasStartNode } from './has-start-node';
|
||||
import { noCodeImports } from './no-code-imports';
|
||||
import { noEmptySetNodes } from './no-empty-set-nodes';
|
||||
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
|
||||
import { noUnreachableNodes } from './no-unreachable-nodes';
|
||||
|
|
@ -32,6 +33,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
|
|||
hasStartNode,
|
||||
noHardcodedCredentials,
|
||||
noUnnecessaryCodeNodes,
|
||||
noCodeImports,
|
||||
expressionsReferenceExistingNodes,
|
||||
validRequiredParameters,
|
||||
validOptionsValues,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import type { BinaryCheck, SimpleWorkflow } from '../types';
|
||||
|
||||
/**
|
||||
* Patterns that detect library import attempts in JavaScript code:
|
||||
* - require('module') / require("module")
|
||||
* - import ... from 'module'
|
||||
* - import('module') (dynamic import)
|
||||
*/
|
||||
const JS_IMPORT_PATTERNS = [/\brequire\s*\(/, /\bimport\s+[\s\S]*?\s+from\s+['"`]/, /\bimport\s*\(/];
|
||||
|
||||
/**
|
||||
* Patterns that detect library import attempts in Python code:
|
||||
* - import module
|
||||
* - from module import name
|
||||
* - __import__('module')
|
||||
*/
|
||||
const PYTHON_IMPORT_PATTERNS = [
|
||||
/^\s*import\s+\w+/m,
|
||||
/^\s*from\s+\w+\s+import\s+/m,
|
||||
/\b__import__\(/,
|
||||
];
|
||||
|
||||
const LANGUAGE_CONFIG: Record<string, [string, RegExp[]]> = {
|
||||
javaScript: ['jsCode', JS_IMPORT_PATTERNS],
|
||||
pythonNative: ['pythonCode', PYTHON_IMPORT_PATTERNS],
|
||||
};
|
||||
|
||||
function getCodeAndPatterns(
|
||||
parameters: Record<string, unknown>,
|
||||
): { code: string; patterns: RegExp[] } | null {
|
||||
const language = (parameters.language as string) ?? 'javaScript';
|
||||
const config = LANGUAGE_CONFIG[language];
|
||||
if (!config) return null;
|
||||
const [codeKey, patterns] = config;
|
||||
const code = parameters[codeKey] as string | undefined;
|
||||
if (!code) return null;
|
||||
return { code, patterns };
|
||||
}
|
||||
|
||||
function detectImports(code: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some((pattern) => pattern.test(code));
|
||||
}
|
||||
|
||||
export const noCodeImports: BinaryCheck = {
|
||||
name: 'no_code_imports',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow) {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const nodesWithImports: string[] = [];
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.type !== 'n8n-nodes-base.code') continue;
|
||||
|
||||
const params = node.parameters as Record<string, unknown> | undefined;
|
||||
if (!params) continue;
|
||||
|
||||
const codeInfo = getCodeAndPatterns(params);
|
||||
if (!codeInfo) continue;
|
||||
|
||||
if (detectImports(codeInfo.code, codeInfo.patterns)) {
|
||||
nodesWithImports.push(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass: nodesWithImports.length === 0,
|
||||
...(nodesWithImports.length > 0
|
||||
? {
|
||||
comment: `Code nodes with library imports: ${nodesWithImports.join(', ')}. Library imports are disallowed in Code nodes.`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
|||
import type { ParseAndValidateResult, ValidationWarning } from '../types';
|
||||
import { stripImportStatements } from '../utils/extract-code';
|
||||
|
||||
/**
|
||||
* Error thrown when workflow code parsing fails.
|
||||
* Used by MCP tools to distinguish parse errors from other failures.
|
||||
*/
|
||||
export class WorkflowCodeParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'WorkflowCodeParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for ParseValidateHandler
|
||||
*/
|
||||
|
|
@ -201,7 +212,7 @@ export class ParseValidateHandler {
|
|||
code: code.substring(0, 500),
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
throw new WorkflowCodeParseError(
|
||||
`Failed to parse generated workflow code: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export { generateCodeBuilderThreadId } from './utils/code-builder-session';
|
|||
|
||||
// Core utilities for MCP integration
|
||||
export { NodeTypeParser } from './utils/node-type-parser';
|
||||
export { ParseValidateHandler } from './handlers/parse-validate-handler';
|
||||
export { ParseValidateHandler, WorkflowCodeParseError } from './handlers/parse-validate-handler';
|
||||
export { createCodeBuilderSearchTool } from './tools/code-builder-search.tool';
|
||||
export { createCodeBuilderGetTool } from './tools/code-builder-get.tool';
|
||||
export type { CodeBuilderGetToolOptions } from './tools/code-builder-get.tool';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type {
|
|||
export {
|
||||
NodeTypeParser,
|
||||
ParseValidateHandler,
|
||||
WorkflowCodeParseError,
|
||||
createCodeBuilderSearchTool,
|
||||
createCodeBuilderGetTool,
|
||||
createGetSuggestedNodesTool,
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
@ -11,9 +11,9 @@
|
|||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:dev": "vitest --silent=false"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
|
|
@ -23,12 +23,19 @@
|
|||
],
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/config": "workspace:*"
|
||||
"@n8n/config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vitest-mock-extended": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"n8n-workflow": "workspace:*",
|
||||
"xss": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"@n8n/permissions": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export interface AiGatewayUsageEntry {
|
|||
provider: string;
|
||||
model: string;
|
||||
timestamp: number;
|
||||
creditsDeducted: number;
|
||||
cost: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,48 +17,48 @@ const VALID_SORT_OPTIONS = [
|
|||
|
||||
export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number];
|
||||
|
||||
const FILTER_OPTIONS = {
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
name: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
projectId: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
// todo: can probably include others here as well?
|
||||
};
|
||||
const filterSchema = z
|
||||
.object({
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
name: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
projectId: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
// todo: can probably include others here as well?
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Filter schema - only allow specific properties
|
||||
const filterSchema = z.object(FILTER_OPTIONS).strict();
|
||||
// ---------------------
|
||||
// Parameter Validators
|
||||
// ---------------------
|
||||
// Public API restricts projectId to a single string
|
||||
const publicApiFilterSchema = filterSchema.extend({ projectId: z.string().optional() }).strict();
|
||||
|
||||
// Filter parameter validation
|
||||
const filterValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const parsed: unknown = jsonParse(val);
|
||||
const makeFilterValidator = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx): z.infer<T> | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
return filterSchema.parse(parsed);
|
||||
} catch (e) {
|
||||
const result = schema.safeParse(jsonParse(val));
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter fields',
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid filter format',
|
||||
path: ['filter'],
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const filterValidator = makeFilterValidator(filterSchema);
|
||||
const publicApiFilterValidator = makeFilterValidator(publicApiFilterSchema);
|
||||
|
||||
// SortBy parameter validation
|
||||
const sortByValidator = z
|
||||
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
|
||||
.optional();
|
||||
|
|
@ -71,6 +71,6 @@ export class ListDataTableQueryDto extends Z.class({
|
|||
|
||||
export class PublicApiListDataTableQueryDto extends Z.class({
|
||||
...publicApiPaginationSchema,
|
||||
filter: filterValidator,
|
||||
filter: publicApiFilterValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { CreateDataTableColumnDto } from './create-data-table-column.dto';
|
||||
import { dataTableNameSchema } from '../../schemas/data-table.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class PublicApiCreateDataTableDto extends Z.class({
|
||||
name: dataTableNameSchema,
|
||||
columns: z.array(CreateDataTableColumnDto.schema),
|
||||
fileId: z.string().optional(),
|
||||
hasHeaders: z.boolean().optional(),
|
||||
projectId: z.string().optional(),
|
||||
}) {}
|
||||
|
|
@ -163,11 +163,12 @@ export {
|
|||
type RoleProjectMembersResponse,
|
||||
} from './roles/role-project-members-response.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
export { OidcConfigDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
|
||||
export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto';
|
||||
|
||||
export { CreateDataTableDto } from './data-table/create-data-table.dto';
|
||||
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
|
||||
export { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto';
|
||||
export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto';
|
||||
export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto';
|
||||
export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto';
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import { z } from 'zod';
|
|||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export const OIDC_PROMPT_VALUES = ['none', 'login', 'consent', 'select_account', 'create'] as const;
|
||||
|
||||
export class OidcConfigDto extends Z.class({
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
discoveryEndpoint: z.string().url(),
|
||||
loginEnabled: z.boolean().optional().default(false),
|
||||
prompt: z
|
||||
.enum(['none', 'login', 'consent', 'select_account', 'create'])
|
||||
.optional()
|
||||
.default('select_account'),
|
||||
prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'),
|
||||
authenticationContextClassReference: z.array(z.string()).default([]),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export class SecuritySettingsDto extends Z.class({
|
|||
publishedPersonalWorkflowsCount: z.number(),
|
||||
sharedPersonalWorkflowsCount: z.number(),
|
||||
sharedPersonalCredentialsCount: z.number(),
|
||||
managedByEnv: z.boolean(),
|
||||
}) {}
|
||||
|
||||
export class UpdateSecuritySettingsDto extends Z.class({
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export interface FrontendSettings {
|
|||
defaultLocale: string;
|
||||
userManagement: IUserManagementSettings;
|
||||
sso: {
|
||||
managedByEnv: boolean;
|
||||
saml: {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
|
|
@ -217,7 +218,7 @@ export interface FrontendSettings {
|
|||
};
|
||||
aiGateway?: {
|
||||
enabled: boolean;
|
||||
creditsQuota: number;
|
||||
budget: number;
|
||||
};
|
||||
ai: {
|
||||
allowSendingParameterValues: boolean;
|
||||
|
|
@ -281,6 +282,7 @@ export type FrontendModuleSettings = {
|
|||
localGatewayDisabled: boolean;
|
||||
proxyEnabled: boolean;
|
||||
optinModalDismissed: boolean;
|
||||
cloudManaged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ export type { HeartbeatMessage } from './push/heartbeat';
|
|||
export { createHeartbeatMessage, heartbeatMessageSchema } from './push/heartbeat';
|
||||
export type { SendWorkerStatusMessage } from './push/worker';
|
||||
|
||||
export type { FavoriteResourceType } from './schemas/favorites.schema';
|
||||
export { FAVORITE_RESOURCE_TYPES } from './schemas/favorites.schema';
|
||||
|
||||
export type { BannerName } from './schemas/banner-name.schema';
|
||||
export { ViewableMimeTypes } from './schemas/binary-data.schema';
|
||||
export { passwordSchema, createPasswordSchema } from './schemas/password.schema';
|
||||
|
|
@ -247,9 +250,9 @@ export {
|
|||
} from './schemas/community-package.schema';
|
||||
|
||||
export {
|
||||
publicApiCreatedCredentialSchema,
|
||||
type PublicApiCreatedCredential,
|
||||
} from './schemas/credential-created.schema';
|
||||
publicApiCredentialResponseSchema,
|
||||
type PublicApiCredentialResponse,
|
||||
} from './schemas/credential-response.schema';
|
||||
|
||||
export {
|
||||
instanceAiEventTypeSchema,
|
||||
|
|
@ -275,8 +278,6 @@ export {
|
|||
workflowSetupNodeSchema,
|
||||
errorPayloadSchema,
|
||||
filesystemRequestPayloadSchema,
|
||||
instanceAiFilesystemResponseSchema,
|
||||
instanceAiGatewayCapabilitiesSchema,
|
||||
mcpToolSchema,
|
||||
mcpToolCallRequestSchema,
|
||||
mcpToolCallResultSchema,
|
||||
|
|
@ -299,6 +300,8 @@ export {
|
|||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
InstanceAiGatewayCapabilitiesDto,
|
||||
InstanceAiFilesystemResponseDto,
|
||||
applyBranchReadOnlyOverrides,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export type WorkflowFailedToActivate = {
|
|||
data: {
|
||||
workflowId: string;
|
||||
errorMessage: string;
|
||||
errorDescription?: string;
|
||||
nodeId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -316,12 +316,12 @@ describe('agent-run-reducer', () => {
|
|||
describe('tool execution', () => {
|
||||
it('tool-call adds to toolCallsById and timeline', () => {
|
||||
const state = stateWithRun('run-1', 'root');
|
||||
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'update-tasks'));
|
||||
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'task-control'));
|
||||
|
||||
const tc = state.toolCallsById['tc-1'];
|
||||
expect(tc).toBeDefined();
|
||||
expect(tc.toolCallId).toBe('tc-1');
|
||||
expect(tc.toolName).toBe('update-tasks');
|
||||
expect(tc.toolName).toBe('task-control');
|
||||
expect(tc.isLoading).toBe(true);
|
||||
expect(tc.renderHint).toBe('tasks');
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe('passwordSchema with N8N_PASSWORD_MIN_LENGTH', () => {
|
|||
});
|
||||
|
||||
const importFreshSchema = async () => {
|
||||
jest.resetModules();
|
||||
vi.resetModules();
|
||||
return await import('../password.schema');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Plain credential row after creation
|
||||
* Used by the public API to validate results from `CredentialsService.createUnmanagedCredential`.
|
||||
* Plain credential row in public API responses.
|
||||
*/
|
||||
export const publicApiCreatedCredentialSchema = z.object({
|
||||
export const publicApiCredentialResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
|
|
@ -17,4 +16,4 @@ export const publicApiCreatedCredentialSchema = z.object({
|
|||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type PublicApiCreatedCredential = z.infer<typeof publicApiCreatedCredentialSchema>;
|
||||
export type PublicApiCredentialResponse = z.infer<typeof publicApiCredentialResponseSchema>;
|
||||
|
|
@ -45,11 +45,11 @@ export type DataTableColumn = z.infer<typeof dataTableColumnSchema>;
|
|||
export type DataTableListFilter = {
|
||||
id?: string | string[];
|
||||
projectId?: string | string[];
|
||||
name?: string;
|
||||
name?: string | string[];
|
||||
};
|
||||
|
||||
export type DataTableListOptions = Partial<ListDataTableQueryDto> & {
|
||||
filter: { projectId: string };
|
||||
filter: DataTableListFilter;
|
||||
};
|
||||
|
||||
export type DataTableListSortBy = ListDataTableQueryDto['sortBy'];
|
||||
|
|
|
|||
2
packages/@n8n/api-types/src/schemas/favorites.schema.ts
Normal file
2
packages/@n8n/api-types/src/schemas/favorites.schema.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder';
|
||||
export const FAVORITE_RESOURCE_TYPES = ['workflow', 'project', 'dataTable', 'folder'] as const;
|
||||
|
|
@ -431,13 +431,13 @@ export const toolCategorySchema = z.object({
|
|||
});
|
||||
export type ToolCategory = z.infer<typeof toolCategorySchema>;
|
||||
|
||||
export const instanceAiGatewayCapabilitiesSchema = z.object({
|
||||
export class InstanceAiGatewayCapabilitiesDto extends Z.class({
|
||||
rootPath: z.string(),
|
||||
tools: z.array(mcpToolSchema).default([]),
|
||||
hostIdentifier: z.string().optional(),
|
||||
toolCategories: z.array(toolCategorySchema).default([]),
|
||||
});
|
||||
export type InstanceAiGatewayCapabilities = z.infer<typeof instanceAiGatewayCapabilitiesSchema>;
|
||||
}) {}
|
||||
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem bridge payloads (browser ↔ server round-trip)
|
||||
|
|
@ -448,10 +448,10 @@ export const filesystemRequestPayloadSchema = z.object({
|
|||
toolCall: mcpToolCallRequestSchema,
|
||||
});
|
||||
|
||||
export const instanceAiFilesystemResponseSchema = z.object({
|
||||
export class InstanceAiFilesystemResponseDto extends Z.class({
|
||||
result: mcpToolCallResultSchema.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
}) {}
|
||||
|
||||
export const tasksUpdatePayloadSchema = z.object({
|
||||
tasks: taskListSchema,
|
||||
|
|
@ -544,7 +544,7 @@ export type InstanceAiThreadTitleUpdatedEvent = Extract<
|
|||
{ type: 'thread-title-updated' }
|
||||
>;
|
||||
|
||||
export type InstanceAiFilesystemResponse = z.infer<typeof instanceAiFilesystemResponseSchema>;
|
||||
export type InstanceAiFilesystemResponse = InstanceType<typeof InstanceAiFilesystemResponseDto>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API types
|
||||
|
|
@ -965,7 +965,7 @@ const RESEARCH_RENDER_HINT_TOOLS = new Set(['research-with-agent']);
|
|||
const PLANNER_RENDER_HINT_TOOLS = new Set(['plan']);
|
||||
|
||||
export function getRenderHint(toolName: string): InstanceAiToolCallState['renderHint'] {
|
||||
if (toolName === 'update-tasks') return 'tasks';
|
||||
if (toolName === 'task-control') return 'tasks';
|
||||
if (toolName === 'delegate') return 'delegate';
|
||||
if (BUILDER_RENDER_HINT_TOOLS.has(toolName)) return 'builder';
|
||||
if (DATA_TABLE_RENDER_HINT_TOOLS.has(toolName)) return 'data-table';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "jest"],
|
||||
"types": ["node", "vitest/globals"],
|
||||
"baseUrl": "src",
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
|
||||
},
|
||||
|
|
|
|||
4
packages/@n8n/api-types/vite.config.ts
Normal file
4
packages/@n8n/api-types/vite.config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default mergeConfig(defineConfig({}), vitestConfig);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -36,11 +36,13 @@ describe('eligibleModules', () => {
|
|||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'favorites',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
'otel',
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -63,11 +65,13 @@ describe('eligibleModules', () => {
|
|||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'favorites',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
'otel',
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
'instance-ai',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export class ModuleRegistry {
|
|||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'favorites',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
'otel',
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
@ -94,11 +96,20 @@ export class ModuleRegistry {
|
|||
for (const moduleName of modules ?? this.eligibleModules) {
|
||||
try {
|
||||
await import(`${modulesDir}/${moduleName}/${moduleName}.module`);
|
||||
} catch {
|
||||
} catch (primaryError) {
|
||||
try {
|
||||
await import(`${modulesDir}/${moduleName}.ee/${moduleName}.module`);
|
||||
} catch (error) {
|
||||
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
|
||||
const loggedError =
|
||||
primaryError instanceof Error &&
|
||||
'code' in primaryError &&
|
||||
primaryError.code !== 'MODULE_NOT_FOUND'
|
||||
? primaryError
|
||||
: error;
|
||||
throw new MissingModuleError(
|
||||
moduleName,
|
||||
loggedError instanceof Error ? loggedError.message : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ export const MODULE_NAMES = [
|
|||
'ldap',
|
||||
'quick-connect',
|
||||
'workflow-builder',
|
||||
'favorites',
|
||||
'redaction',
|
||||
'instance-registry',
|
||||
'instance-ai',
|
||||
'otel',
|
||||
'token-exchange',
|
||||
'instance-version-history',
|
||||
'encryption-key-manager',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
@ -11,9 +11,9 @@
|
|||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:dev": "vitest --silent=false"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
|
|
@ -25,6 +25,10 @@
|
|||
"axios": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*"
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vitest-mock-extended": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ export class ClientOAuth2 {
|
|||
// Axios rejects the promise by default for all status codes 4xx.
|
||||
// We override this to reject promises only on 5xxs
|
||||
validateStatus: (status) => status < 500,
|
||||
// Disable axios's built-in proxy handling so requests are routed
|
||||
// through n8n's global proxy agents (HttpProxyManager / HttpsProxyManager)
|
||||
// instead of being double-proxied in corporate proxy-chain environments.
|
||||
proxy: false,
|
||||
};
|
||||
|
||||
if (options.ignoreSSLIssues) {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe('ClientOAuth2', () => {
|
|||
}),
|
||||
});
|
||||
|
||||
const axiosSpy = jest.spyOn(axios, 'request');
|
||||
const axiosSpy = vi.spyOn(axios, 'request');
|
||||
|
||||
await makeTokenCall();
|
||||
|
||||
|
|
@ -71,6 +71,7 @@ describe('ClientOAuth2', () => {
|
|||
url: config.accessTokenUri,
|
||||
method: 'POST',
|
||||
data: 'refresh_token=test&grant_type=refresh_token',
|
||||
proxy: false,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe('CredentialsFlow', () => {
|
|||
nock.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('#getToken', () => {
|
||||
const createAuthClient = ({
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe('PKCE Flow', () => {
|
|||
nock.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('PKCE Authorization Code Flow', () => {
|
||||
const createPkceClient = (clientSecret?: string) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "jest"],
|
||||
"types": ["node", "vitest/globals"],
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
|
|
|
|||
14
packages/@n8n/client-oauth2/vite.config.ts
Normal file
14
packages/@n8n/client-oauth2/vite.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/node';
|
||||
import path from 'node:path';
|
||||
|
||||
export default mergeConfig(
|
||||
defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
vitestConfig,
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/computer-use",
|
||||
"version": "0.3.0",
|
||||
"description": "Local AI gateway for n8n Instance AI — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||
"version": "0.4.0",
|
||||
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||
"bin": {
|
||||
"n8n-computer-use": "dist/cli.js"
|
||||
},
|
||||
|
|
@ -39,11 +39,11 @@
|
|||
"@jitsi/robotjs": "^0.6.21",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"@n8n/mcp-browser": "workspace:*",
|
||||
"@napi-rs/image": "^1.12.0",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
"eventsource": "^3.0.6",
|
||||
"node-screenshots": "^0.2.8",
|
||||
"picocolors": "catalog:",
|
||||
"sharp": "^0.34.5",
|
||||
"yargs-parser": "21.1.1",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "catalog:"
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ function shouldShowHelp(): boolean {
|
|||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
n8n-computer-use — Local AI gateway for n8n Instance AI
|
||||
n8n-computer-use — Local AI gateway for n8n AI Assistant
|
||||
|
||||
Usage:
|
||||
npx @n8n/computer-use <url> Start daemon (n8n connects to you)
|
||||
|
|
|
|||
|
|
@ -14,25 +14,18 @@ export function sanitizeForTerminal(value: string): string {
|
|||
return value.replace(CONTROL_CHARS_RE, '');
|
||||
}
|
||||
|
||||
export const RESOURCE_DECISIONS: Record<ResourceDecision, string> = {
|
||||
allowOnce: 'Allow once',
|
||||
allowForSession: 'Allow for session',
|
||||
alwaysAllow: 'Always allow',
|
||||
denyOnce: 'Deny once',
|
||||
alwaysDeny: 'Always deny',
|
||||
} as const;
|
||||
|
||||
export async function cliConfirmResourceAccess(
|
||||
resource: AffectedResource,
|
||||
): Promise<ResourceDecision> {
|
||||
const answer = await select({
|
||||
message: `Grant permission — ${resource.toolGroup}: ${sanitizeForTerminal(resource.resource)}`,
|
||||
choices: (Object.entries(RESOURCE_DECISIONS) as Array<[ResourceDecision, string]>).map(
|
||||
([value, name]) => ({
|
||||
name,
|
||||
value,
|
||||
}),
|
||||
),
|
||||
choices: [
|
||||
{ name: 'Allow once', value: 'allowOnce' as ResourceDecision },
|
||||
{ name: 'Allow for session', value: 'allowForSession' as ResourceDecision },
|
||||
{ name: 'Always allow', value: 'alwaysAllow' as ResourceDecision },
|
||||
{ name: 'Deny once', value: 'denyOnce' as ResourceDecision },
|
||||
{ name: 'Always deny', value: 'alwaysDeny' as ResourceDecision },
|
||||
],
|
||||
});
|
||||
|
||||
return answer;
|
||||
|
|
|
|||
|
|
@ -326,6 +326,70 @@ describe('POST /connect — origin allowlist', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /connect — concurrent confirmation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /connect — concurrent confirmation', () => {
|
||||
it('returns 409 when a confirmation prompt is already in progress', async () => {
|
||||
let resolveConfirm!: (value: boolean) => void;
|
||||
const confirmConnect = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () => await new Promise<boolean>((resolve) => (resolveConfirm = resolve)),
|
||||
);
|
||||
const { port, close } = await startTestDaemon(
|
||||
{ filesystem: { dir: tmpDir } },
|
||||
{ confirmConnect },
|
||||
);
|
||||
try {
|
||||
// First connection — hangs waiting for user confirmation
|
||||
const first = post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
|
||||
|
||||
// Wait for the first request to reach confirmConnect
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Second connection attempt while confirmation is pending
|
||||
const second = await post(port, '/connect', { url: 'http://localhost:5679', token: 'tok' });
|
||||
expect(second.status).toBe(409);
|
||||
expect(second.body.error).toMatch(/confirmation is already in progress/);
|
||||
|
||||
// Resolve the first confirmation and await its response
|
||||
resolveConfirm(false);
|
||||
await first;
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a new connection after a pending confirmation completes', async () => {
|
||||
let resolveConfirm!: (value: boolean) => void;
|
||||
const confirmConnect = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<boolean>((resolve) => (resolveConfirm = resolve)),
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
const { port, close } = await startTestDaemon(
|
||||
{ filesystem: { dir: tmpDir } },
|
||||
{ confirmConnect },
|
||||
);
|
||||
try {
|
||||
// First connection — hangs then gets rejected
|
||||
const first = post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
resolveConfirm(false);
|
||||
await first;
|
||||
|
||||
// Second connection after confirmation cleared — should succeed
|
||||
const second = await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
|
||||
expect(second.status).toBe(200);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /connect — already connected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface DaemonState {
|
|||
session: GatewaySession | null;
|
||||
connectedAt: string | null;
|
||||
connectedUrl: string | null;
|
||||
confirmingConnection: boolean;
|
||||
}
|
||||
|
||||
const state: DaemonState = {
|
||||
|
|
@ -51,6 +52,7 @@ const state: DaemonState = {
|
|||
session: null,
|
||||
connectedAt: null,
|
||||
connectedUrl: null,
|
||||
confirmingConnection: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -180,6 +182,12 @@ async function handleConnect(req: http.IncomingMessage, res: http.ServerResponse
|
|||
return;
|
||||
}
|
||||
|
||||
// Reject concurrent connection attempts while a confirmation prompt is active
|
||||
if (state.confirmingConnection) {
|
||||
jsonResponse(req, res, 409, { error: 'A connection confirmation is already in progress.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedOrigin: string;
|
||||
try {
|
||||
parsedOrigin = new URL(url).origin;
|
||||
|
|
@ -206,7 +214,13 @@ async function handleConnect(req: http.IncomingMessage, res: http.ServerResponse
|
|||
const defaults = store.getDefaults(state.config);
|
||||
const session = new GatewaySession(defaults, store);
|
||||
|
||||
const approved = await daemonOptions.confirmConnect(url, session);
|
||||
state.confirmingConnection = true;
|
||||
let approved: boolean;
|
||||
try {
|
||||
approved = await daemonOptions.confirmConnect(url, session);
|
||||
} finally {
|
||||
state.confirmingConnection = false;
|
||||
}
|
||||
if (!approved) {
|
||||
jsonResponse(req, res, 403, { error: 'Connection rejected by user.' });
|
||||
return;
|
||||
|
|
|
|||
260
packages/@n8n/computer-use/src/gateway-client.test.ts
Normal file
260
packages/@n8n/computer-use/src/gateway-client.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* Unit tests for GatewayClient.checkPermissions (tested indirectly via dispatchToolCall).
|
||||
*
|
||||
* The private checkPermissions method is exercised by mocking the tool registry
|
||||
* so we can control what AffectedResources are returned, then asserting side-effects
|
||||
* on the session and the decisions taken.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks — must be declared before imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Suppress logger noise during tests
|
||||
jest.mock('./logger', () => ({
|
||||
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
printAuthFailure: jest.fn(),
|
||||
printDisconnected: jest.fn(),
|
||||
printReconnecting: jest.fn(),
|
||||
printReinitFailed: jest.fn(),
|
||||
printReinitializing: jest.fn(),
|
||||
printToolCall: jest.fn(),
|
||||
printToolResult: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock tool modules that pull in native/ESM-only dependencies
|
||||
jest.mock('./tools/shell', () => ({
|
||||
['ShellModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
|
||||
}));
|
||||
jest.mock('./tools/filesystem', () => ({
|
||||
filesystemReadTools: [],
|
||||
filesystemWriteTools: [],
|
||||
}));
|
||||
jest.mock('./tools/screenshot', () => ({
|
||||
['ScreenshotModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
|
||||
}));
|
||||
jest.mock('./tools/mouse-keyboard', () => ({
|
||||
['MouseKeyboardModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] },
|
||||
}));
|
||||
jest.mock('./tools/browser', () => ({
|
||||
['BrowserModule']: { create: jest.fn().mockResolvedValue(null) },
|
||||
}));
|
||||
|
||||
import type { GatewayConfig } from './config';
|
||||
import { GatewayClient } from './gateway-client';
|
||||
import type { GatewaySession } from './gateway-session';
|
||||
import type { AffectedResource, ConfirmResourceAccess, ToolDefinition } from './tools/types';
|
||||
import { INSTANCE_RESOURCE_DECISION_KEYS } from './tools/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeConfig(permissionConfirmation: 'client' | 'instance' = 'client'): GatewayConfig {
|
||||
return {
|
||||
logLevel: 'silent',
|
||||
port: 0,
|
||||
allowedOrigins: [],
|
||||
filesystem: { dir: '/' },
|
||||
computer: { shell: { timeout: 30_000 } },
|
||||
browser: { defaultBrowser: 'chrome' },
|
||||
permissions: {},
|
||||
permissionConfirmation,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<GatewaySession> = {}): jest.Mocked<GatewaySession> {
|
||||
return {
|
||||
dir: '/tmp',
|
||||
check: jest.fn().mockReturnValue('ask'),
|
||||
getAllPermissions: jest.fn().mockReturnValue({
|
||||
filesystemRead: 'allow',
|
||||
filesystemWrite: 'ask',
|
||||
shell: 'ask',
|
||||
computer: 'deny',
|
||||
browser: 'ask',
|
||||
}),
|
||||
setPermissions: jest.fn(),
|
||||
setDir: jest.fn(),
|
||||
getGroupMode: jest.fn().mockReturnValue('allow'),
|
||||
allowForSession: jest.fn(),
|
||||
clearSessionRules: jest.fn(),
|
||||
alwaysAllow: jest.fn(),
|
||||
alwaysDeny: jest.fn(),
|
||||
flush: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as jest.Mocked<GatewaySession>;
|
||||
}
|
||||
|
||||
const SHELL_RESOURCE: AffectedResource = {
|
||||
toolGroup: 'shell',
|
||||
resource: 'npm install',
|
||||
description: 'Run npm install',
|
||||
};
|
||||
|
||||
/** A minimal tool definition that returns a given resource list and a simple result. */
|
||||
function makeTool(resources: AffectedResource[]): ToolDefinition {
|
||||
return {
|
||||
name: 'test_tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { parse: (x: unknown) => x } as ToolDefinition['inputSchema'],
|
||||
annotations: {},
|
||||
execute: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }),
|
||||
getAffectedResources: jest.fn().mockResolvedValue(resources),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal GatewayClient with a single registered tool, bypassing the
|
||||
* normal async initialisation (uploadCapabilities / getAllDefinitions).
|
||||
*/
|
||||
function makeClient(
|
||||
session: jest.Mocked<GatewaySession>,
|
||||
confirmResourceAccess: ConfirmResourceAccess,
|
||||
permissionConfirmation: 'client' | 'instance' = 'client',
|
||||
resources: AffectedResource[] = [SHELL_RESOURCE],
|
||||
): GatewayClient {
|
||||
const client = new GatewayClient({
|
||||
url: 'http://localhost:5678',
|
||||
apiKey: 'tok',
|
||||
config: makeConfig(permissionConfirmation),
|
||||
session,
|
||||
confirmResourceAccess,
|
||||
});
|
||||
|
||||
const tool = makeTool(resources);
|
||||
|
||||
// Inject the tool directly so dispatchToolCall finds it without network I/O.
|
||||
// @ts-expect-error — accessing private field for testing
|
||||
client.allDefinitions = [tool];
|
||||
// @ts-expect-error — accessing private field for testing
|
||||
client.definitionMap = new Map([[tool.name, tool]]);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GatewayClient.checkPermissions', () => {
|
||||
describe('client mode', () => {
|
||||
it('allowOnce — does not modify session permissions', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn().mockResolvedValue('allowOnce');
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await client['dispatchToolCall']('test_tool', {});
|
||||
|
||||
expect(confirmResourceAccess).toHaveBeenCalledWith(SHELL_RESOURCE);
|
||||
expect(session.setPermissions).not.toHaveBeenCalled();
|
||||
expect(session.alwaysAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allowForSession — allows the specific resource for the session', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn().mockResolvedValue('allowForSession');
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await client['dispatchToolCall']('test_tool', {});
|
||||
|
||||
expect(session.allowForSession).toHaveBeenCalledWith('shell', 'npm install');
|
||||
expect(session.setPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alwaysAllow — delegates to session.alwaysAllow', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysAllow');
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await client['dispatchToolCall']('test_tool', {});
|
||||
|
||||
expect(session.alwaysAllow).toHaveBeenCalledWith('shell', 'npm install');
|
||||
});
|
||||
|
||||
it('denyOnce — throws without persisting', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn().mockResolvedValue('denyOnce');
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
|
||||
'User denied access',
|
||||
);
|
||||
expect(session.setPermissions).not.toHaveBeenCalled();
|
||||
expect(session.alwaysDeny).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alwaysDeny — persists and throws', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysDeny');
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
|
||||
'User permanently denied',
|
||||
);
|
||||
expect(session.alwaysDeny).toHaveBeenCalledWith('shell', 'npm install');
|
||||
});
|
||||
|
||||
it('skips confirmation when session.check returns allow', async () => {
|
||||
const session = makeSession({ check: jest.fn().mockReturnValue('allow') });
|
||||
const confirmResourceAccess = jest.fn();
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await client['dispatchToolCall']('test_tool', {});
|
||||
|
||||
expect(confirmResourceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws immediately when session.check returns deny', async () => {
|
||||
const session = makeSession({ check: jest.fn().mockReturnValue('deny') });
|
||||
const confirmResourceAccess = jest.fn();
|
||||
const client = makeClient(session, confirmResourceAccess);
|
||||
|
||||
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
|
||||
'User permanently denied',
|
||||
);
|
||||
expect(confirmResourceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance mode', () => {
|
||||
it('throws GATEWAY_CONFIRMATION_REQUIRED with the 3-option list', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn();
|
||||
const client = makeClient(session, confirmResourceAccess, 'instance');
|
||||
|
||||
await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow(
|
||||
'GATEWAY_CONFIRMATION_REQUIRED::',
|
||||
);
|
||||
|
||||
// Extract the JSON payload from the error
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await client['dispatchToolCall']('test_tool', {});
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : '';
|
||||
}
|
||||
let json: { options: string[] };
|
||||
try {
|
||||
json = JSON.parse(errorMessage.slice('GATEWAY_CONFIRMATION_REQUIRED::'.length)) as {
|
||||
options: string[];
|
||||
};
|
||||
} catch {
|
||||
throw new Error(`Failed to parse GATEWAY_CONFIRMATION_REQUIRED payload: ${errorMessage}`);
|
||||
}
|
||||
expect(json.options).toEqual(INSTANCE_RESOURCE_DECISION_KEYS);
|
||||
});
|
||||
|
||||
it('applies _confirmation decision in instance mode without prompting', async () => {
|
||||
const session = makeSession();
|
||||
const confirmResourceAccess = jest.fn();
|
||||
const client = makeClient(session, confirmResourceAccess, 'instance');
|
||||
|
||||
// Simulate the agent sending back _confirmation=allowForSession
|
||||
await client['dispatchToolCall']('test_tool', { _confirmation: 'allowForSession' });
|
||||
|
||||
expect(session.allowForSession).toHaveBeenCalledWith('shell', 'npm install');
|
||||
expect(confirmResourceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ import {
|
|||
type ResourceDecision,
|
||||
type ToolDefinition,
|
||||
GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
|
||||
RESOURCE_DECISION_KEYS,
|
||||
INSTANCE_RESOURCE_DECISION_KEYS,
|
||||
} from './tools/types';
|
||||
import { formatErrorResult } from './tools/utils';
|
||||
|
||||
|
|
@ -443,7 +443,7 @@ export class GatewayClient {
|
|||
toolGroup: resource.toolGroup,
|
||||
resource: resource.resource,
|
||||
description: resource.description,
|
||||
options: RESOURCE_DECISION_KEYS,
|
||||
options: INSTANCE_RESOURCE_DECISION_KEYS,
|
||||
})}`,
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
18
packages/@n8n/computer-use/src/sharp.d.ts
vendored
18
packages/@n8n/computer-use/src/sharp.d.ts
vendored
|
|
@ -1,18 +0,0 @@
|
|||
declare module 'sharp' {
|
||||
interface Sharp {
|
||||
resize(width: number, height?: number): Sharp;
|
||||
png(): Sharp;
|
||||
jpeg(options?: { quality?: number }): Sharp;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
|
||||
}
|
||||
|
||||
interface SharpOptions {
|
||||
raw?: { width: number; height: number; channels: 1 | 2 | 3 | 4 };
|
||||
}
|
||||
|
||||
function sharp(input?: Buffer | string, options?: SharpOptions): Sharp;
|
||||
|
||||
// eslint-disable-next-line import-x/no-default-export
|
||||
export default sharp;
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ import { screenshotTool, screenshotRegionTool } from './screenshot';
|
|||
|
||||
jest.mock('node-screenshots');
|
||||
|
||||
const mockSharp = jest.fn<unknown, unknown[]>();
|
||||
jest.mock('sharp', () => ({
|
||||
const mockFromRgbaPixels = jest.fn<unknown, unknown[]>();
|
||||
jest.mock('@napi-rs/image', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
default: (...args: unknown[]) => mockSharp(...args),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Transformer: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
fromRgbaPixels: (...args: unknown[]) => mockFromRgbaPixels(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const MockMonitor = Monitor as jest.MockedClass<typeof Monitor>;
|
||||
|
|
@ -75,13 +78,11 @@ function makeMockMonitor(opts: {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// sharp(buffer, opts)[.resize()].jpeg().toBuffer() → fake JPEG
|
||||
const mockToBuffer = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg'));
|
||||
const mockJpeg = jest.fn().mockReturnValue({ toBuffer: mockToBuffer });
|
||||
const mockJpeg = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg'));
|
||||
const mockResize = jest.fn();
|
||||
const pipeline = { resize: mockResize, jpeg: mockJpeg };
|
||||
mockResize.mockReturnValue(pipeline);
|
||||
mockSharp.mockReturnValue(pipeline);
|
||||
mockFromRgbaPixels.mockReturnValue(pipeline);
|
||||
});
|
||||
|
||||
describe('screen_screenshot tool', () => {
|
||||
|
|
@ -136,7 +137,7 @@ describe('screen_screenshot tool', () => {
|
|||
|
||||
await screenshotTool.execute({}, DUMMY_CONTEXT);
|
||||
|
||||
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
|
||||
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
|
||||
expect(pipeline.resize).toHaveBeenCalledWith(1920, 1080);
|
||||
});
|
||||
|
||||
|
|
@ -151,7 +152,7 @@ describe('screen_screenshot tool', () => {
|
|||
|
||||
await screenshotTool.execute({}, DUMMY_CONTEXT);
|
||||
|
||||
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
|
||||
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
|
||||
// No HiDPI resize, but LLM downscale kicks in (1920x1080 → 1024x576)
|
||||
expect(pipeline.resize).toHaveBeenCalledWith(1024, 576);
|
||||
});
|
||||
|
|
@ -252,7 +253,7 @@ describe('screen_screenshot_region tool', () => {
|
|||
await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT);
|
||||
|
||||
// Cropped image (800×600 physical) must be resized to logical 400×300
|
||||
const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock };
|
||||
const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock };
|
||||
expect(pipeline.resize).toHaveBeenCalledWith(400, 300);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ async function toJpeg(
|
|||
logicalWidth?: number,
|
||||
logicalHeight?: number,
|
||||
): Promise<Buffer> {
|
||||
const { default: sharp } = await import('sharp');
|
||||
let pipeline = sharp(rawBuffer, { raw: { width, height, channels: 4 } });
|
||||
const { Transformer } = await import('@napi-rs/image');
|
||||
let pipeline = Transformer.fromRgbaPixels(rawBuffer, width, height);
|
||||
if (logicalWidth && logicalHeight && (width !== logicalWidth || height !== logicalHeight)) {
|
||||
pipeline = pipeline.resize(logicalWidth, logicalHeight);
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ async function toJpeg(
|
|||
const scale = maxDim / Math.max(w, h);
|
||||
pipeline = pipeline.resize(Math.round(w * scale), Math.round(h * scale));
|
||||
}
|
||||
return await pipeline.jpeg({ quality: 85 }).toBuffer();
|
||||
return await pipeline.jpeg(85);
|
||||
}
|
||||
|
||||
export const screenshotTool: ToolDefinition<typeof screenshotSchema> = {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ export const RESOURCE_DECISION_KEYS: ResourceDecision[] = [
|
|||
'alwaysDeny',
|
||||
];
|
||||
|
||||
/** Reduced option set sent to the n8n instance UI — no persistent allow/deny to avoid fatigue. */
|
||||
export const INSTANCE_RESOURCE_DECISION_KEYS: ResourceDecision[] = [
|
||||
'denyOnce',
|
||||
'allowOnce',
|
||||
'allowForSession',
|
||||
];
|
||||
|
||||
/** Prefix used to signal a gateway confirmation is required (instance mode). */
|
||||
export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.16.0",
|
||||
"version": "2.17.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@ describe('ExpressionEngineConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT', () => {
|
||||
test('overrides idleTimeout', () => {
|
||||
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT: '60' });
|
||||
const config = Container.get(ExpressionEngineConfig);
|
||||
expect(config.idleTimeout).toBe(60);
|
||||
});
|
||||
|
||||
test('parses "0" as the number 0 (distinct from undefined/unset)', () => {
|
||||
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT: '0' });
|
||||
const config = Container.get(ExpressionEngineConfig);
|
||||
expect(config.idleTimeout).toBe(0);
|
||||
expect(config.idleTimeout).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('N8N_EXPRESSION_ENGINE_TIMEOUT', () => {
|
||||
test('overrides bridgeTimeout', () => {
|
||||
jest.replaceProperty(process, 'env', { N8N_EXPRESSION_ENGINE_TIMEOUT: '1000' });
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class PostHogConfig {
|
||||
/** PostHog project API key for product analytics. */
|
||||
@Env('N8N_DIAGNOSTICS_POSTHOG_API_KEY')
|
||||
apiKey: string = 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo';
|
||||
apiKey: string = 'phc_kMstNfAgBcBkWSh6KdsgN09heqqNe5VNmalHP1Ni9Q4';
|
||||
|
||||
/** PostHog API host URL. */
|
||||
@Env('N8N_DIAGNOSTICS_POSTHOG_API_HOST')
|
||||
apiHost: string = 'https://us.i.posthog.com';
|
||||
apiHost: string = 'https://ph.n8n.io';
|
||||
}
|
||||
|
||||
@Config
|
||||
|
|
|
|||
|
|
@ -33,4 +33,8 @@ export class ExpressionEngineConfig {
|
|||
/** Memory limit in MB for the V8 isolate used by the VM bridge. */
|
||||
@Env('N8N_EXPRESSION_ENGINE_MEMORY_LIMIT')
|
||||
bridgeMemoryLimit: number = 128;
|
||||
|
||||
/** If set, scale the pool to 0 warm isolates after this many seconds with no acquire. */
|
||||
@Env('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT')
|
||||
idleTimeout?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,66 @@ export class InstanceSettingsLoaderConfig {
|
|||
*/
|
||||
@Env('N8N_INSTANCE_OWNER_PASSWORD_HASH')
|
||||
ownerPasswordHash: string = '';
|
||||
|
||||
// --- SSO ---
|
||||
|
||||
/** When true, SSO connection config is read from env vars on every startup and the UI is locked. */
|
||||
@Env('N8N_SSO_MANAGED_BY_ENV')
|
||||
ssoManagedByEnv: boolean = false;
|
||||
|
||||
/** User role provisioning mode: disabled, instance_role, or instance_and_project_roles. */
|
||||
@Env('N8N_SSO_USER_ROLE_PROVISIONING')
|
||||
ssoUserRoleProvisioning: string = 'disabled';
|
||||
|
||||
// --- OIDC ---
|
||||
|
||||
@Env('N8N_SSO_OIDC_CLIENT_ID')
|
||||
oidcClientId: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_CLIENT_SECRET')
|
||||
oidcClientSecret: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_DISCOVERY_ENDPOINT')
|
||||
oidcDiscoveryEndpoint: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_LOGIN_ENABLED')
|
||||
oidcLoginEnabled: boolean = false;
|
||||
|
||||
/** Values can be found in packages/@n8n/api-types/src/dto/oidc/config.dto.ts */
|
||||
@Env('N8N_SSO_OIDC_PROMPT')
|
||||
oidcPrompt: string = 'select_account';
|
||||
|
||||
/** Comma-separated ACR values */
|
||||
@Env('N8N_SSO_OIDC_ACR_VALUES')
|
||||
oidcAcrValues: string = '';
|
||||
|
||||
/**
|
||||
* When true, security policy settings are managed via environment variables.
|
||||
* On every startup the security policy will be overridden by env vars.
|
||||
* When false (default), security policy env vars are ignored even if set.
|
||||
*/
|
||||
@Env('N8N_SECURITY_POLICY_MANAGED_BY_ENV')
|
||||
securityPolicyManagedByEnv: boolean = false;
|
||||
|
||||
@Env('N8N_MFA_ENFORCED_ENABLED')
|
||||
mfaEnforcedEnabled: boolean = false;
|
||||
|
||||
@Env('N8N_PERSONAL_SPACE_PUBLISHING_ENABLED')
|
||||
personalSpacePublishingEnabled: boolean = true;
|
||||
|
||||
@Env('N8N_PERSONAL_SPACE_SHARING_ENABLED')
|
||||
personalSpaceSharingEnabled: boolean = true;
|
||||
|
||||
// --- SAML ---
|
||||
|
||||
/** XML metadata string from the identity provider. */
|
||||
@Env('N8N_SSO_SAML_METADATA')
|
||||
samlMetadata: string = '';
|
||||
|
||||
/** URL to fetch SAML metadata from (mutually exclusive with metadata). */
|
||||
@Env('N8N_SSO_SAML_METADATA_URL')
|
||||
samlMetadataUrl: string = '';
|
||||
|
||||
@Env('N8N_SSO_SAML_LOGIN_ENABLED')
|
||||
samlLoginEnabled: boolean = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export const LOG_SCOPES = [
|
|||
'instance-ai',
|
||||
'instance-version-history',
|
||||
'instance-settings-loader',
|
||||
'instance-registry',
|
||||
'encryption-key-manager',
|
||||
] as const;
|
||||
|
||||
export type LogScope = (typeof LOG_SCOPES)[number];
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue