mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge remote-tracking branch 'origin/master' into RobTF9/context-178-spike-retrieving-agent-session-logs-via-mcp
This commit is contained in:
commit
a43a46ef52
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"issues": 141
|
||||
"issues": 141
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
|
||||
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
|
||||
from anywhere with a terminal, resume tomorrow.
|
||||
from anywhere, resume tomorrow.
|
||||
|
||||
This is a separate devcontainer config from the laptop one in
|
||||
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
|
||||
|
|
@ -11,17 +11,61 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
|
|||
|
||||
## One-time setup (~5 min)
|
||||
|
||||
1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces).
|
||||
Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode.
|
||||
Give both secrets access to `n8n-io/n8n`.
|
||||
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
|
||||
2. Give the GitHub CLI the codespace scope:
|
||||
1. Give the GitHub CLI the codespace scope:
|
||||
|
||||
```bash
|
||||
gh auth refresh -h github.com -s codespace
|
||||
```
|
||||
|
||||
## Daily flow
|
||||
2. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces)
|
||||
if your client needs them. Give each secret access to `n8n-io/n8n`.
|
||||
- `ANTHROPIC_API_KEY` for Claude Code in the terminal (`pnpm session`).
|
||||
Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from
|
||||
`claude setup-token`. The desktop app does not need either secret.
|
||||
- `OPENROUTER_API_KEY` for OpenCode.
|
||||
|
||||
## Choose a client
|
||||
|
||||
All clients run the agent, its tools, and builds in the Codespace. Your laptop
|
||||
shows the conversation only.
|
||||
|
||||
| Client | Command | Use it when |
|
||||
|---|---|---|
|
||||
| Claude Code desktop app | `pnpm session ssh-config` once | You want the full desktop experience. |
|
||||
| Claude Code in VS Code | Open the codespace in VS Code or the browser | You already work in VS Code. |
|
||||
| Claude Code in a terminal | `pnpm session` | You want a plain terminal, or you are on a remote machine. |
|
||||
| OpenCode | `pnpm session:opencode` | You use OpenCode. See [Local OpenCode clients](#local-opencode-clients). |
|
||||
|
||||
### Claude Code desktop app
|
||||
|
||||
1. From a local checkout, add an SSH host for your codespace:
|
||||
|
||||
```bash
|
||||
pnpm session ssh-config
|
||||
```
|
||||
|
||||
The command creates or starts the codespace. It writes the host
|
||||
`n8n-codespace` to `~/.ssh/n8n-codespace.conf` and includes that file from
|
||||
`~/.ssh/config`.
|
||||
2. In the desktop app, add an SSH connection to the host `n8n-codespace`.
|
||||
3. Open the folder `/workspaces/n8n`, or a worktree under `/workspaces`.
|
||||
|
||||
The desktop app installs its own Claude Code on the codespace and signs in
|
||||
with your desktop account. Sessions continue when you close the app. They stop
|
||||
when the codespace stops.
|
||||
|
||||
- **Run `pnpm session ssh-config` again after you recreate the codespace.**
|
||||
- **If the connection times out**, the codespace is probably starting. Run
|
||||
`pnpm session ssh-config`, then connect again.
|
||||
- **Test the connection** with `ssh n8n-codespace`.
|
||||
|
||||
### Claude Code in VS Code
|
||||
|
||||
The dev container installs the Claude Code extension. Open the codespace in
|
||||
VS Code or the browser, then open the Claude Code panel. Sign in from the
|
||||
extension if you did not add `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||
|
||||
### Claude Code in a terminal
|
||||
|
||||
```bash
|
||||
pnpm session # attach Claude Code (creates everything on first run)
|
||||
|
|
@ -33,6 +77,7 @@ pnpm session:opencode --legacy # use the remote TUI in tmux
|
|||
pnpm session fix-flaky # Claude Code in a separate worktree
|
||||
pnpm session ls # what's running
|
||||
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
|
||||
pnpm session ssh-config # add the n8n-codespace SSH host for the desktop app
|
||||
pnpm session stop # end of day: billing stops, disk survives
|
||||
pnpm session rm # delete the codespace
|
||||
```
|
||||
|
|
@ -332,11 +377,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
|
|||
Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents
|
||||
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
|
||||
and repo investigation. The worker keeps the token in its environment and puts
|
||||
only an environment reference in the OpenCode config. Claude login registers
|
||||
the same server without writing the token to disk. Forks have no secrets and
|
||||
only an environment reference in the OpenCode config. Forks have no secrets and
|
||||
skip it. Tell the agent to call `get_flaky_context` first — it returns the rules
|
||||
the tools assume.
|
||||
|
||||
`post-start.mjs` registers the server for Claude Code on each container start.
|
||||
Its `headersHelper` reads the token from the secrets file on each connect, so
|
||||
the server works in every client. The token is not copied into the Claude Code
|
||||
MCP configuration.
|
||||
|
||||
## Quality and security skills (Claude plugins)
|
||||
|
||||
Claude sessions can also load the private skills from the
|
||||
|
|
@ -398,7 +447,8 @@ to pull the skills into context.
|
|||
|
||||
## Viewing the dev UI locally
|
||||
|
||||
Two terminal windows:
|
||||
`pnpm session tunnel` works with every client. With the terminal client, use
|
||||
two terminal windows:
|
||||
|
||||
```bash
|
||||
pnpm session # window 1: attach the agent session
|
||||
|
|
@ -448,10 +498,10 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
|||
agent injects them into VS Code sessions only; they're delivered
|
||||
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
|
||||
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
|
||||
interactive shells (bashrc), and in the `pnpm session` prelude. If Claude
|
||||
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
|
||||
started Claude did not source the file. Run
|
||||
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
|
||||
interactive shells (bashrc), and in the `pnpm session` prelude. Desktop app
|
||||
sessions do not source it. `git`, `gh`, `scripts/codespace-env.mjs`, and the
|
||||
`flaky` MCP entry read the file when they run, so they work everywhere. New
|
||||
code that needs a secret must do the same.
|
||||
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
|
||||
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
|
||||
sessions only. Other processes read them from `codespaces-env.sh`, and a
|
||||
|
|
@ -460,7 +510,7 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
|||
polled correctly as its owner while `dev:up` in the same session saw an empty
|
||||
box name, printed the localhost URL, and did not share the port. The helper
|
||||
reads `/workspaces/.codespaces/shared`, which is always correct.
|
||||
- **You cannot paste images into a remote Claude session.** Image paste reads
|
||||
- **You cannot paste images into a terminal Claude session.** Image paste reads
|
||||
the clipboard of the machine where `claude` runs — the codespace, not your
|
||||
laptop. Drag the file into the VS Code explorer (or
|
||||
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@
|
|||
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
|
||||
>"$HOME/.claude.json"
|
||||
|
||||
# Register the Flaky MCP server for Claude Code. The config keeps a literal
|
||||
# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is
|
||||
# not written to disk. Forks have no repo secrets and skip this.
|
||||
if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then
|
||||
claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \
|
||||
--header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Register the credential helper in the user config, because Codespaces
|
||||
# regenerates the managed /etc/gitconfig. When the env token is missing, the
|
||||
# system helper exits 0 without output, and git falls through to ours.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,13 @@
|
|||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
"forwardPorts": [8080, 5678],
|
||||
// Grant the Codespace token read access to the private skills and harness repositories.
|
||||
// Each user authorizes this access once when they create the Codespace.
|
||||
"customizations": {
|
||||
// Claude Code in VS Code or the browser editor. It runs in the codespace.
|
||||
"vscode": {
|
||||
"extensions": ["anthropic.claude-code"]
|
||||
},
|
||||
// Grant the Codespace token read access to the private skills and harness repositories.
|
||||
// Each user authorizes this access once when they create the Codespace.
|
||||
"codespaces": {
|
||||
"repositories": {
|
||||
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { rmSync, writeFileSync } from 'node:fs';
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
|
||||
import { codespaceSecret } from '../../scripts/codespace-env.mjs';
|
||||
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
|
||||
|
||||
const STATUS_FILE = '/tmp/post-start-status.json';
|
||||
|
|
@ -49,6 +50,42 @@ function addMarketplace() {
|
|||
return add('marketplace add (retry)');
|
||||
}
|
||||
|
||||
// Claude Code reads the Flaky token when it connects. An env reference such as
|
||||
// ${FLAKY_MCP_TOKEN} fails in processes that did not load the secrets file, for
|
||||
// example the desktop app's SSH server. The helper reads the file on each connect,
|
||||
// so it also gets a rotated token. The token is not copied into ~/.claude.json.
|
||||
const FLAKY_HEADERS_HELPER =
|
||||
'. /usr/local/lib/codespaces-env.sh; printf %s "{\\"Authorization\\":\\"Bearer $FLAKY_MCP_TOKEN\\"}"';
|
||||
|
||||
function registerFlakyMcp() {
|
||||
// Forks have no repository secrets.
|
||||
const url = codespaceSecret('FLAKY_MCP_URL');
|
||||
if (!url || !codespaceSecret('FLAKY_MCP_TOKEN')) return;
|
||||
|
||||
let current;
|
||||
try {
|
||||
current = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.flaky;
|
||||
} catch {
|
||||
// No readable config yet: register the server below.
|
||||
}
|
||||
if (current?.url === url && current.headersHelper === FLAKY_HEADERS_HELPER && !current.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) tryRun('flaky mcp remove', 'claude', ['mcp', 'remove', '--scope', 'user', 'flaky']);
|
||||
const config = { type: 'http', url, headersHelper: FLAKY_HEADERS_HELPER };
|
||||
tryRun('flaky mcp add', 'claude', [
|
||||
'mcp',
|
||||
'add-json',
|
||||
'--scope',
|
||||
'user',
|
||||
'flaky',
|
||||
JSON.stringify(config),
|
||||
]);
|
||||
}
|
||||
|
||||
registerFlakyMcp();
|
||||
|
||||
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
|
||||
|
||||
const installed = [];
|
||||
|
|
|
|||
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal file
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { aliasHostConfig, withInclude } from '../../scripts/cloud-session-ssh-config.mjs';
|
||||
|
||||
const GH_CONFIG = `Host cs.ominous-doodle.master
|
||||
User node
|
||||
ProxyCommand gh cs ssh -c ominous-doodle --stdio
|
||||
UserKnownHostsFile=/dev/null
|
||||
`;
|
||||
|
||||
test('replaces the gh host name with the fixed alias', () => {
|
||||
assert.equal(
|
||||
aliasHostConfig(GH_CONFIG),
|
||||
GH_CONFIG.replace('cs.ominous-doodle.master', 'n8n-codespace'),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gh output without exactly one proxied host', () => {
|
||||
assert.throws(() => aliasHostConfig(''));
|
||||
assert.throws(() => aliasHostConfig(`${GH_CONFIG}Host other\n`));
|
||||
assert.throws(() => aliasHostConfig('Host cs.x\n\tUser node\n'));
|
||||
});
|
||||
|
||||
test('adds the Include line at the top once', () => {
|
||||
const include = 'Include ~/.ssh/n8n-codespace.conf';
|
||||
assert.equal(withInclude(''), `${include}\n`);
|
||||
|
||||
const updated = withInclude('Host github.com\n\tUser git\n');
|
||||
assert.equal(updated, `${include}\n\nHost github.com\n\tUser git\n`);
|
||||
assert.equal(withInclude(updated), undefined);
|
||||
});
|
||||
1
.github/workflows/build-windows.yml
vendored
1
.github/workflows/build-windows.yml
vendored
|
|
@ -23,6 +23,7 @@ on:
|
|||
paths:
|
||||
- '**/package.json'
|
||||
- '**/turbo.json'
|
||||
- 'packages/cli/scripts/build.mjs'
|
||||
- '.github/workflows/build-windows.yml'
|
||||
- '.github/actions/setup-nodejs/**'
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ build.log
|
|||
tcr-dry.log
|
||||
.agent-setup/
|
||||
.agent-recordings/
|
||||
.vitest-profile/
|
||||
.pi-subagents/
|
||||
sbom-source.cdx.json
|
||||
*.junit.xml
|
||||
|
|
|
|||
129
CHANGELOG.md
129
CHANGELOG.md
|
|
@ -1,3 +1,132 @@
|
|||
# [2.42.0](https://github.com/n8n-io/n8n/compare/n8n@2.41.0...n8n@2.42.0) (2026-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **AI Agent Node:** Let an AI Agent Tool use its own tools under a pre-v3 parent agent ([#39160](https://github.com/n8n-io/n8n/issues/39160)) ([60d0598](https://github.com/n8n-io/n8n/commit/60d0598a69ca75ebddb203163fbaa0e2fcb78ae0))
|
||||
* Align MCP workflow headings and remove outdated notice ([#39463](https://github.com/n8n-io/n8n/issues/39463)) ([b096147](https://github.com/n8n-io/n8n/commit/b096147894f6d77b771fe3edc14c6ccd18e26c50))
|
||||
* **API:** Accept workflow descriptions on creation ([#39705](https://github.com/n8n-io/n8n/issues/39705)) ([6c1edac](https://github.com/n8n-io/n8n/commit/6c1edac204abb4d241850fbeef95ea1ea720fc5c))
|
||||
* **API:** Allow extendsCredential in workflow create/update ([#39243](https://github.com/n8n-io/n8n/issues/39243)) ([9e7bc66](https://github.com/n8n-io/n8n/commit/9e7bc6691c2feee43fbe549010462e3f40ee6261))
|
||||
* **API:** Validate OpenAPI path parameter names ([#39067](https://github.com/n8n-io/n8n/issues/39067)) ([a2a2638](https://github.com/n8n-io/n8n/commit/a2a26384611b72fd682439036b11bd2b1d78924f))
|
||||
* **Azure OpenAI Chat Model Node:** Make the Azure Entra credential sign in as the app ([#39377](https://github.com/n8n-io/n8n/issues/39377)) ([2f98090](https://github.com/n8n-io/n8n/commit/2f98090707df0366543d79bddc8040b7d0c0a9d7))
|
||||
* **core:** Close Chat integration runtime on shutdown and let followers send ([#38958](https://github.com/n8n-io/n8n/issues/38958)) ([3628dc1](https://github.com/n8n-io/n8n/commit/3628dc1f04d5134d9a8eb861b8ba440e464fee2d))
|
||||
* **core:** Count a database ping as successful when its reply arrives during event-loop lag ([#39645](https://github.com/n8n-io/n8n/issues/39645)) ([bda9453](https://github.com/n8n-io/n8n/commit/bda94539539e77fce4c42a6086a8c187ee7b8de3))
|
||||
* **core:** Enforce one instance report per day with a unique index ([#39536](https://github.com/n8n-io/n8n/issues/39536)) ([20807d2](https://github.com/n8n-io/n8n/commit/20807d2d3d6b25d6df2e4b5b502c18bca784180a))
|
||||
* **core:** Gate the AI Assistant project list on the team-projects license ([#39235](https://github.com/n8n-io/n8n/issues/39235)) ([dec7c71](https://github.com/n8n-io/n8n/commit/dec7c712c7b9f565b3ba0176c3ca65ec7dfdea04))
|
||||
* **core:** Keep one failed binary-data dir from aborting the delete batch ([#39194](https://github.com/n8n-io/n8n/issues/39194)) ([34f77c0](https://github.com/n8n-io/n8n/commit/34f77c059b5a1176e5299c225fc3979125fba6d0))
|
||||
* **core:** Keep OpenTelemetry export working after a restart when Sentry is enabled ([#38480](https://github.com/n8n-io/n8n/issues/38480)) ([dbefd92](https://github.com/n8n-io/n8n/commit/dbefd920b5d82ecd9900fb4e129e1fe342078045))
|
||||
* **core:** Link MCP workflow access errors to workflow settings ([#39738](https://github.com/n8n-io/n8n/issues/39738)) ([9f5c752](https://github.com/n8n-io/n8n/commit/9f5c752cd179d52a91459dc98871c026fce49b51))
|
||||
* **core:** Record live n8n Assistant test runs as verification evidence ([#39361](https://github.com/n8n-io/n8n/issues/39361)) ([fb35797](https://github.com/n8n-io/n8n/commit/fb357977b5dfc7b1a88890a2b1bc76618a792983))
|
||||
* **core:** Release the expression isolate when the webhook response ends ([#39138](https://github.com/n8n-io/n8n/issues/39138)) ([82cd04c](https://github.com/n8n-io/n8n/commit/82cd04c9a6af23483728c6dd3c8ca34d8520974d))
|
||||
* **core:** Remove checkpoints when deleting agent sessions ([#39175](https://github.com/n8n-io/n8n/issues/39175)) ([6a678f0](https://github.com/n8n-io/n8n/commit/6a678f02a011c033afea3d7a71c847b8854417d0))
|
||||
* **core:** Remove feature flag for MCP Registry in AI Assistant ([#39018](https://github.com/n8n-io/n8n/issues/39018)) ([ecfec19](https://github.com/n8n-io/n8n/commit/ecfec194b481568d1d1de954068de320b5cf803b))
|
||||
* **core:** Restore pubsub subscriptions after Redis reconnect and detect half-open subscriber connections ([#39174](https://github.com/n8n-io/n8n/issues/39174)) ([3f76dcc](https://github.com/n8n-io/n8n/commit/3f76dccc32a204a48422fbc40af7f951d4c367f5))
|
||||
* **core:** Resume only private sessions in Preview ([#39151](https://github.com/n8n-io/n8n/issues/39151)) ([dda9a56](https://github.com/n8n-io/n8n/commit/dda9a56cb7faa6610d7f36b3870015a08f091ae9))
|
||||
* **core:** Retry each file removal in the byte store's delete ([#39193](https://github.com/n8n-io/n8n/issues/39193)) ([7bebbe4](https://github.com/n8n-io/n8n/commit/7bebbe403358313f4de843d8237ad5dceec9fd9f))
|
||||
* **core:** Retry instance reports that cross the UTC midnight boundary ([#39290](https://github.com/n8n-io/n8n/issues/39290)) ([f914770](https://github.com/n8n-io/n8n/commit/f9147709a639cb01cc209d3157945d53673216ca))
|
||||
* **core:** Retry the recursive removal of an execution's binary-data directory ([#39185](https://github.com/n8n-io/n8n/issues/39185)) ([3a31c38](https://github.com/n8n-io/n8n/commit/3a31c3875146cd5d728ad31d3e01d13a7ca8c0cb))
|
||||
* **core:** Return test webhook response when the call lands on another main ([#39382](https://github.com/n8n-io/n8n/issues/39382)) ([73bdae5](https://github.com/n8n-io/n8n/commit/73bdae55c655c4500a3054469c3443813469c7b4))
|
||||
* **core:** Stop retrying instance reports the receiver rejects with 400 or 413 ([#39502](https://github.com/n8n-io/n8n/issues/39502)) ([d9c9d6e](https://github.com/n8n-io/n8n/commit/d9c9d6e616eb3ef63e9077de142556508aa853df))
|
||||
* **core:** Store queue job results only for executions this process enqueued ([#39712](https://github.com/n8n-io/n8n/issues/39712)) ([5d508ec](https://github.com/n8n-io/n8n/commit/5d508ec1e1e75d51919bd38d32344846fff8ea9e))
|
||||
* **core:** Use Sessions tab terminology in n8n Assistant ([#39265](https://github.com/n8n-io/n8n/issues/39265)) ([43d7cc7](https://github.com/n8n-io/n8n/commit/43d7cc755ee5702ceb9aa41b26692fd5dc2ed482))
|
||||
* **core:** Validate token payload before resolving the user ([#39078](https://github.com/n8n-io/n8n/issues/39078)) ([7d1bae5](https://github.com/n8n-io/n8n/commit/7d1bae51fcbe1e93828a8cd5ef951f4a36749c57))
|
||||
* **editor:** Fetch publication status when a new workflow becomes saved ([#38382](https://github.com/n8n-io/n8n/issues/38382)) ([d658d18](https://github.com/n8n-io/n8n/commit/d658d182e4d58e716c7a57fd7177716a75b35cb9))
|
||||
* **editor:** Improve Agent layout and sub-agent guidance ([#39791](https://github.com/n8n-io/n8n/issues/39791)) ([3daa34d](https://github.com/n8n-io/n8n/commit/3daa34dba0ce883c7d31f8c4733afbb242bd89a9))
|
||||
* **editor:** Keep agent builder chat messages within the panel ([#39233](https://github.com/n8n-io/n8n/issues/39233)) ([9142b47](https://github.com/n8n-io/n8n/commit/9142b47bdf34ed96462e4ebb1c3ae752cfec606f))
|
||||
* **editor:** Keep agent capability chips within card width ([#39532](https://github.com/n8n-io/n8n/issues/39532)) ([9329d1f](https://github.com/n8n-io/n8n/commit/9329d1f7526195f1611fd838744b9b055590edec))
|
||||
* **editor:** Keep quick-connect OAuth credentials out of lists until the user authorizes ([#39297](https://github.com/n8n-io/n8n/issues/39297)) ([4c2f220](https://github.com/n8n-io/n8n/commit/4c2f2209712ff6892206946c2964d96d45cf528c))
|
||||
* **editor:** Keep typed text in the AI Assistant chat rename input ([#39570](https://github.com/n8n-io/n8n/issues/39570)) ([42dc399](https://github.com/n8n-io/n8n/commit/42dc3991c103711e1d1d7b502238613c13edea76))
|
||||
* **editor:** Make agent configuration header more compact ([#39172](https://github.com/n8n-io/n8n/issues/39172)) ([1c82574](https://github.com/n8n-io/n8n/commit/1c82574f86532271789c995c3c656b77c05f0df6))
|
||||
* **editor:** Move agent AI entry point below header ([#39318](https://github.com/n8n-io/n8n/issues/39318)) ([afa5fc3](https://github.com/n8n-io/n8n/commit/afa5fc3b1a216a7863222556a43e3953031f8186))
|
||||
* **editor:** Prefill the server URL in the Mistral Vibe connector link ([#39518](https://github.com/n8n-io/n8n/issues/39518)) ([d4999a2](https://github.com/n8n-io/n8n/commit/d4999a2b2f46c93c0fe1f48a451d09b6ca5b245d))
|
||||
* **editor:** Remove half-checked permissions from system instance roles and restore the Member preset ([#38652](https://github.com/n8n-io/n8n/issues/38652)) ([c114a18](https://github.com/n8n-io/n8n/commit/c114a18c877944036517106e1fccb8894fa40c1b))
|
||||
* **editor:** Shorten the Assistant name in navigation and settings ([#39288](https://github.com/n8n-io/n8n/issues/39288)) ([5cc6640](https://github.com/n8n-io/n8n/commit/5cc6640ae8ab0e46659051b53ec5aae2a3c762b1))
|
||||
* **editor:** Show a cached role at once and align the personal space block in the instance role editor ([#39394](https://github.com/n8n-io/n8n/issues/39394)) ([e648610](https://github.com/n8n-io/n8n/commit/e648610eae8c182e507681029202798fe41e3423))
|
||||
* **editor:** Show Agent Builder activity on prompt submission ([#39501](https://github.com/n8n-io/n8n/issues/39501)) ([8a0d833](https://github.com/n8n-io/n8n/commit/8a0d833080d4fced718f354a4f4e8bfc2b310176))
|
||||
* **editor:** Show project icons in Agent breadcrumbs ([#39649](https://github.com/n8n-io/n8n/issues/39649)) ([1fe19db](https://github.com/n8n-io/n8n/commit/1fe19db437e785aae631d8c4c4d1191497d9884f))
|
||||
* **editor:** Show when update available for community packages if only verified packages are enabled ([#39424](https://github.com/n8n-io/n8n/issues/39424)) ([070d9fe](https://github.com/n8n-io/n8n/commit/070d9fea4e3d731cdddc006db8abebd17ff9698d))
|
||||
* **editor:** Sorting data table rows by unknown column returns raw database error ([#39733](https://github.com/n8n-io/n8n/issues/39733)) ([b8cda55](https://github.com/n8n-io/n8n/commit/b8cda55e5713eec92328abc22dc794afe3c17bfc))
|
||||
* **Google Cloud Storage Node:** Preserve request headers on object create ([#35218](https://github.com/n8n-io/n8n/issues/35218)) ([bc9faf2](https://github.com/n8n-io/n8n/commit/bc9faf230a1b97f6c052d7e32a344d2d967fdc5a))
|
||||
* **googleFirebaseCloudFirestore Node:** Preserve field types for numeric strings and falsy values ([#27399](https://github.com/n8n-io/n8n/issues/27399)) ([b3d4cfe](https://github.com/n8n-io/n8n/commit/b3d4cfe311be36911c9c0ddc94310a0775431c7d))
|
||||
* **LinkedIn Node:** Validate post text ([#39201](https://github.com/n8n-io/n8n/issues/39201)) ([d1cd34b](https://github.com/n8n-io/n8n/commit/d1cd34b375a37f03e4ae2c266f0d8fbf49793d2d))
|
||||
* Make n8n-node release work with release-it 21 ([#39378](https://github.com/n8n-io/n8n/issues/39378)) ([d23f949](https://github.com/n8n-io/n8n/commit/d23f949801d5838d53a0c9aac0f6aefd267a5047))
|
||||
* **MCP Client Node:** Terminate session before closing the client ([#39526](https://github.com/n8n-io/n8n/issues/39526)) ([11acaa3](https://github.com/n8n-io/n8n/commit/11acaa3f2bf9d33260c94b1fff3a07a4662fe24c))
|
||||
* **Microsoft Teams Node:** Correct the v2 resource-locator copy ([#39443](https://github.com/n8n-io/n8n/issues/39443)) ([a924724](https://github.com/n8n-io/n8n/commit/a9247247af0bd3501455a929789058153b36deb8))
|
||||
* **Mistral Cloud Node:** Support citation reference chunks ([#38952](https://github.com/n8n-io/n8n/issues/38952)) ([a9bc9c5](https://github.com/n8n-io/n8n/commit/a9bc9c57e7f9cde1894decf7e8161daaaae712aa))
|
||||
* **Notion Node:** Clarify simplified output for AI workflow generation ([#39051](https://github.com/n8n-io/n8n/issues/39051)) ([67b70ea](https://github.com/n8n-io/n8n/commit/67b70ea814fc9ff32722b35057f1271c424ca1cb))
|
||||
* Preserve tables in AI workflow builder web fetch ([#38879](https://github.com/n8n-io/n8n/issues/38879)) ([07a741b](https://github.com/n8n-io/n8n/commit/07a741b59578ba6110d70bd3eb4e571f203b2c8e))
|
||||
* Preserve tool arguments in reconstructed history ([#39153](https://github.com/n8n-io/n8n/issues/39153)) ([fe0fad5](https://github.com/n8n-io/n8n/commit/fe0fad574337a5fa875a0e8650c0dbcc49a1c272))
|
||||
* Recover running preview chat after reload ([#39311](https://github.com/n8n-io/n8n/issues/39311)) ([98f012e](https://github.com/n8n-io/n8n/commit/98f012e26830bc1e926a4322aa217c93b7feb7a6))
|
||||
* Validate node codex categories during linting ([#39543](https://github.com/n8n-io/n8n/issues/39543)) ([a3aa6c0](https://github.com/n8n-io/n8n/commit/a3aa6c00749ac0905ac51a438b6d63b0d7295cea))
|
||||
* **Wise Node:** Use current sandbox API ([#39255](https://github.com/n8n-io/n8n/issues/39255)) ([3c48cef](https://github.com/n8n-io/n8n/commit/3c48cefad4bbc6e9f69a9fd1c186660adc29cb3b))
|
||||
* **Xero Node:** Send tenant header on every page when returning all items ([#39431](https://github.com/n8n-io/n8n/issues/39431)) ([58e55b5](https://github.com/n8n-io/n8n/commit/58e55b58985fccb2b37640edcdc1e84d45753f5e))
|
||||
* **Zoho CRM Node:** Update logo to current Zoho branding ([#39305](https://github.com/n8n-io/n8n/issues/39305)) ([be12181](https://github.com/n8n-io/n8n/commit/be121813f06414f0f4d0b23adfd29767cec0adc1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add an n8n Assistant onboarding thread for new Cloud signups ([#39730](https://github.com/n8n-io/n8n/issues/39730)) ([d765bbd](https://github.com/n8n-io/n8n/commit/d765bbd68e9d4ee8aef219b4a68bc33bb3afaece))
|
||||
* Add credential descriptions behind an instance flag ([#39177](https://github.com/n8n-io/n8n/issues/39177)) ([57d654f](https://github.com/n8n-io/n8n/commit/57d654f9726c779989f752b15fa02e276e8782bd))
|
||||
* Add Gateway credits option to Agent web search ([#39169](https://github.com/n8n-io/n8n/issues/39169)) ([4dbe8de](https://github.com/n8n-io/n8n/commit/4dbe8de82d070a34f4c87b34716e9457cbd5f0f0))
|
||||
* Add pinned Apply and Apply Continue to the promotion CLI commands ([#39597](https://github.com/n8n-io/n8n/issues/39597)) ([6c57533](https://github.com/n8n-io/n8n/commit/6c57533958da912e1da163f7ac1f60447fb11a76))
|
||||
* **ai-builder:** Add approval settings for agent channel actions ([#39059](https://github.com/n8n-io/n8n/issues/39059)) ([d9f6568](https://github.com/n8n-io/n8n/commit/d9f65684f8bbfd6b08c9e9e3d29842f61f663070))
|
||||
* **ai-builder:** Confirm template selections before finishing the build ([#39517](https://github.com/n8n-io/n8n/issues/39517)) ([80e4084](https://github.com/n8n-io/n8n/commit/80e408442066bb5697d4012d1f2ce835afd637c4))
|
||||
* **ai-builder:** Prompt suggestions and intro on empty agent builder aia ([#39098](https://github.com/n8n-io/n8n/issues/39098)) ([9ee6a8f](https://github.com/n8n-io/n8n/commit/9ee6a8f88b0f47a2b92c511c7c549e1af5a6c41b))
|
||||
* **Anthropic Node:** Add prompt caching to the Message operation ([#35804](https://github.com/n8n-io/n8n/issues/35804)) ([6b9c48f](https://github.com/n8n-io/n8n/commit/6b9c48fe4dba5785958f2167549a2c64033ffaf6))
|
||||
* **API:** Create projects with source IDs and package metadata ([#39218](https://github.com/n8n-io/n8n/issues/39218)) ([8012f01](https://github.com/n8n-io/n8n/commit/8012f017bc80a35cef0c3012033e9089c9db8e03))
|
||||
* **API:** Migrate GET /users endpoints to the decorator pattern ([#38599](https://github.com/n8n-io/n8n/issues/38599)) ([6433934](https://github.com/n8n-io/n8n/commit/6433934db70b10073026235263c35687c5d3f239))
|
||||
* **API:** Migrate users write endpoints to the decorator pattern ([#39120](https://github.com/n8n-io/n8n/issues/39120)) ([428bab3](https://github.com/n8n-io/n8n/commit/428bab3201ad5c1cbfac1e7dc2ca793f9226b770))
|
||||
* **Azure OpenAI Chat Model Node:** Add the project input and the deployment list call ([#39375](https://github.com/n8n-io/n8n/issues/39375)) ([4e3d5c1](https://github.com/n8n-io/n8n/commit/4e3d5c10f1480db790d695c8c91dc626ba02c78d))
|
||||
* **Azure OpenAI Chat Model Node:** Rename to Azure AI Foundry Chat Model ([#39603](https://github.com/n8n-io/n8n/issues/39603)) ([ee9dfd1](https://github.com/n8n-io/n8n/commit/ee9dfd1a377e815667641b21920a16534e0d3dd4))
|
||||
* **core:** Add browser OAuth2 flow for n8n User Auth webhooks ([#39332](https://github.com/n8n-io/n8n/issues/39332)) ([38c92f4](https://github.com/n8n-io/n8n/commit/38c92f40116cdfb3b24d9daeecbf939812dcbe59))
|
||||
* **core:** Add feature flag for granular credential sharing work ([#39293](https://github.com/n8n-io/n8n/issues/39293)) ([fc68bb6](https://github.com/n8n-io/n8n/commit/fc68bb6a8f1bbfb521e2f3bbaf06ec0cf1bbc28c))
|
||||
* **core:** Add shared Agent context access ([#38930](https://github.com/n8n-io/n8n/issues/38930)) ([8a0ca48](https://github.com/n8n-io/n8n/commit/8a0ca48bb72a8fbdcba32bcdc94723d43421f991))
|
||||
* **core:** Add the durable agent message queue foundation ([#39435](https://github.com/n8n-io/n8n/issues/39435)) ([6c592bd](https://github.com/n8n-io/n8n/commit/6c592bdb3306a3cfc29e25872135486c4a45940c))
|
||||
* **core:** Allows custom roles to have view/use/manage access to instance credentials ([#39306](https://github.com/n8n-io/n8n/issues/39306)) ([72a14b0](https://github.com/n8n-io/n8n/commit/72a14b078cd8308c87e1f3e458bd7fce903042f5))
|
||||
* **core:** Answer lastNode webhooks from execution responses ([#38959](https://github.com/n8n-io/n8n/issues/38959)) ([dc3283d](https://github.com/n8n-io/n8n/commit/dc3283da192df70bc8fe7e610b1e4401b87a0301))
|
||||
* **core:** Attach the publishing user to triggered executions ([#39466](https://github.com/n8n-io/n8n/issues/39466)) ([6c4135d](https://github.com/n8n-io/n8n/commit/6c4135ddfb59f4ce3feec71559e343289e5c0b36))
|
||||
* **core:** Authenticate instance reports with the license certificate ([#39217](https://github.com/n8n-io/n8n/issues/39217)) ([a182724](https://github.com/n8n-io/n8n/commit/a1827241362b0440689b1919af259bda6b5e0d42))
|
||||
* **core:** Check credentials against the person running the workflow ([#39685](https://github.com/n8n-io/n8n/issues/39685)) ([4a01756](https://github.com/n8n-io/n8n/commit/4a01756ea453bc7cbdbc869968315934f76e800b))
|
||||
* **core:** Connect Preview and integrations to the agent queue ([#39468](https://github.com/n8n-io/n8n/issues/39468)) ([5ca15b3](https://github.com/n8n-io/n8n/commit/5ca15b37930e6ce41d10fa0eed2a1af3f026b34b))
|
||||
* **core:** Enable Agents by default ([#39312](https://github.com/n8n-io/n8n/issues/39312)) ([94b9627](https://github.com/n8n-io/n8n/commit/94b9627f75f5851cecf396af73d308504039155d))
|
||||
* **core:** Propagate project span attributes to node spans ([#39420](https://github.com/n8n-io/n8n/issues/39420)) ([ea1e4ef](https://github.com/n8n-io/n8n/commit/ea1e4ef249ad858fc3dce3fc9564fa6758478999))
|
||||
* **core:** Report crashed executions to OTel through an execution-crashed event ([#38853](https://github.com/n8n-io/n8n/issues/38853)) ([5131af5](https://github.com/n8n-io/n8n/commit/5131af5ab79ede69564792784cdc5997c2e1d3d2))
|
||||
* **core:** Scope agent preview sessions to their owner ([#39100](https://github.com/n8n-io/n8n/issues/39100)) ([5ba266d](https://github.com/n8n-io/n8n/commit/5ba266db25b794b254922cf68f821bf595751872))
|
||||
* **core:** Set OS process titles for n8n processes ([#39362](https://github.com/n8n-io/n8n/issues/39362)) ([53dc251](https://github.com/n8n-io/n8n/commit/53dc2515081cb534cf224349dca4f1111413591a))
|
||||
* **Databricks Node:** Show the Databricks Trigger in the node picker ([#39176](https://github.com/n8n-io/n8n/issues/39176)) ([27bb095](https://github.com/n8n-io/n8n/commit/27bb095b4580ed775227a5b50c75bdfb3968a670))
|
||||
* **editor:** Add a write lock to the agent builder ([#38758](https://github.com/n8n-io/n8n/issues/38758)) ([3b3ac39](https://github.com/n8n-io/n8n/commit/3b3ac393ba80ba66d4d891cc3ce0b478089ea9dd))
|
||||
* **editor:** Add adaptive comment input to the review activity feed ([#39583](https://github.com/n8n-io/n8n/issues/39583)) ([dda1166](https://github.com/n8n-io/n8n/commit/dda1166326595d0b5af65c6d16f0a5174af527de))
|
||||
* **editor:** Add destination binding dialog ([#39214](https://github.com/n8n-io/n8n/issues/39214)) ([af838bc](https://github.com/n8n-io/n8n/commit/af838bc8ea2b161110330946e4dcd63ff43b63ff))
|
||||
* **editor:** Add expanded N8nMarkdownEditor view ([#39296](https://github.com/n8n-io/n8n/issues/39296)) ([9f89eca](https://github.com/n8n-io/n8n/commit/9f89ecab7eb8dadf01f6b298009d597918f4e513))
|
||||
* **editor:** Add Mistral Vibe to the MCP connect-client picker ([#39183](https://github.com/n8n-io/n8n/issues/39183)) ([183d769](https://github.com/n8n-io/n8n/commit/183d769841bace1c600cb0bb349e69ccfecec8c1))
|
||||
* **editor:** Add queued message controls to agent Preview chat ([#39487](https://github.com/n8n-io/n8n/issues/39487)) ([8a4fd13](https://github.com/n8n-io/n8n/commit/8a4fd13423331e4059472cc568ed6a3e408b3bde))
|
||||
* **editor:** Animate height changes in the MCP callback URLs dialog ([#39490](https://github.com/n8n-io/n8n/issues/39490)) ([8d48d90](https://github.com/n8n-io/n8n/commit/8d48d90d7296ba432a2752720eff9ade67f46751))
|
||||
* **editor:** Create credentials and variables from promotion bindings ([#39342](https://github.com/n8n-io/n8n/issues/39342)) ([2b891f1](https://github.com/n8n-io/n8n/commit/2b891f17a54f41f4aa6b83f4c24e7e2f778f31a9))
|
||||
* **editor:** Displays instance admins (and owner) in all project user lists ([#39419](https://github.com/n8n-io/n8n/issues/39419)) ([96735b2](https://github.com/n8n-io/n8n/commit/96735b20d9ca9e591f765dc9437630305f41e370))
|
||||
* **editor:** Group canvas toolbar buttons in a single container ([#39587](https://github.com/n8n-io/n8n/issues/39587)) ([a96980b](https://github.com/n8n-io/n8n/commit/a96980bcc278400d8e2e34fff61f8ef031d639dc))
|
||||
* **editor:** Group the node credential picker by where each credential comes from ([#39646](https://github.com/n8n-io/n8n/issues/39646)) ([9af0f02](https://github.com/n8n-io/n8n/commit/9af0f02a74021e882762bf1ab1c3420fa9d5857b))
|
||||
* **editor:** Implement Reka UI based context menu for design system ([#37925](https://github.com/n8n-io/n8n/issues/37925)) ([22d18a7](https://github.com/n8n-io/n8n/commit/22d18a7bc9ec714027a6756ea3d6e79f0811535f))
|
||||
* **editor:** Improve Agent Builder intro interactions ([#39590](https://github.com/n8n-io/n8n/issues/39590)) ([b09676e](https://github.com/n8n-io/n8n/commit/b09676e34adfeadf796e5d469967c1ffe81b1386))
|
||||
* **editor:** Make SSO the primary option on the sign-in page when SSO is enabled ([#39268](https://github.com/n8n-io/n8n/issues/39268)) ([0827eb8](https://github.com/n8n-io/n8n/commit/0827eb8eda9ee2436763d5add1154c574a7be6ec))
|
||||
* **editor:** Preview own connected clients on the MCP settings overview ([#39489](https://github.com/n8n-io/n8n/issues/39489)) ([fdc6b9d](https://github.com/n8n-io/n8n/commit/fdc6b9d8921b002ac77b061e3c41f7cf50a8e470))
|
||||
* **editor:** Restyle AI Assistant tab bar and add a tab hover card ([#39625](https://github.com/n8n-io/n8n/issues/39625)) ([88d8390](https://github.com/n8n-io/n8n/commit/88d839022296630c6e74e41fcf7426bc4e5a3359))
|
||||
* **editor:** Revamp the Agent skills modal ([#39545](https://github.com/n8n-io/n8n/issues/39545)) ([4263117](https://github.com/n8n-io/n8n/commit/4263117b480a36510c2491aae0114e93175900b0))
|
||||
* **editor:** Route assistant credit CTAs to top-up for UBB accounts ([#39416](https://github.com/n8n-io/n8n/issues/39416)) ([dd4e7f6](https://github.com/n8n-io/n8n/commit/dd4e7f62b2ed0503b6d6fe550d4ccfc572b8ac1a))
|
||||
* **editor:** Show a restricted node panel in the NDV with contact-admin and replace paths ([#38869](https://github.com/n8n-io/n8n/issues/38869)) ([d42b762](https://github.com/n8n-io/n8n/commit/d42b7621deddaf13bd6353b242dbd817e6167b6f))
|
||||
* **editor:** Show each policy violation with its scope when a save, publish or execution is refused ([#39263](https://github.com/n8n-io/n8n/issues/39263)) ([2ec6ac7](https://github.com/n8n-io/n8n/commit/2ec6ac7c9cd0a73490b7a083b42ef3462db4de8e))
|
||||
* **editor:** Standardize Agent Builder modals ([#39220](https://github.com/n8n-io/n8n/issues/39220)) ([042d167](https://github.com/n8n-io/n8n/commit/042d16718ef74f0f73505f0ebfc868c480261ef5))
|
||||
* **editor:** Suggest Gateway credits after credential errors ([#38818](https://github.com/n8n-io/n8n/issues/38818)) ([7af6741](https://github.com/n8n-io/n8n/commit/7af6741bf29605d6db50cb60fa005bfeef6206a8))
|
||||
* **editor:** Use test label and icon for agent preview ([#38939](https://github.com/n8n-io/n8n/issues/38939)) ([b6b4e54](https://github.com/n8n-io/n8n/commit/b6b4e542515d32e395e8f854da06cc86610c7d1e))
|
||||
* **Microsoft Teams Node:** Add online meeting attendees on Create, Create or Get and Update ([#38850](https://github.com/n8n-io/n8n/issues/38850)) ([eb5eef3](https://github.com/n8n-io/n8n/commit/eb5eef341f068824c68f30e9ce8573e546f88e14))
|
||||
* **Microsoft Teams Node:** Send activity feed notifications to a user ([#38954](https://github.com/n8n-io/n8n/issues/38954)) ([dfe014a](https://github.com/n8n-io/n8n/commit/dfe014a2ba414efc5b37f20da3f2414db7716f4c))
|
||||
* Suggest Gateway credit services during node discovery ([#39631](https://github.com/n8n-io/n8n/issues/39631)) ([2b0719d](https://github.com/n8n-io/n8n/commit/2b0719dc70e1859701352a8589c544fdb0692e6c))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **core:** Avoid terminal outbox scans during publication reconciliation ([#39267](https://github.com/n8n-io/n8n/issues/39267)) ([6272d46](https://github.com/n8n-io/n8n/commit/6272d465df4d4e5455f9223b6649e6228dcb0a59))
|
||||
|
||||
|
||||
# [2.41.0](https://github.com/n8n-io/n8n/compare/n8n@2.40.0...n8n@2.41.0) (2026-09-22)
|
||||
|
||||
|
||||
|
|
|
|||
1
OWNERS
1
OWNERS
|
|
@ -231,6 +231,7 @@ packages/@n8n/mcp-browser-extension/ @n8n-io/nodes
|
|||
|
||||
packages/@n8n/permissions/ @n8n-io/iam
|
||||
packages/@n8n/client-oauth2/ @n8n-io/iam
|
||||
packages/@n8n/inbound-auth/ @n8n-io/iam
|
||||
|
||||
# LiGo
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ component_management:
|
|||
- packages/@n8n/expression-runtime/**
|
||||
- packages/@n8n/extension-sdk/**
|
||||
- packages/@n8n/imap/**
|
||||
- packages/@n8n/inbound-auth/**
|
||||
- packages/@n8n/local-gateway/**
|
||||
- packages/@n8n/permissions/**
|
||||
- packages/@n8n/scan-community-package/**
|
||||
|
|
|
|||
39
docs/generated/postgres-schema/README.md
generated
39
docs/generated/postgres-schema/README.md
generated
|
|
@ -19,12 +19,12 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE |
|
||||
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
|
||||
| [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE |
|
||||
| [public.agent_execution](public.agent_execution.md) | 22 | | BASE TABLE |
|
||||
| [public.agent_execution](public.agent_execution.md) | 23 | | BASE TABLE |
|
||||
| [public.agent_execution_message_links](public.agent_execution_message_links.md) | 5 | | BASE TABLE |
|
||||
| [public.agent_execution_threads](public.agent_execution_threads.md) | 19 | | BASE TABLE |
|
||||
| [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE |
|
||||
| [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE |
|
||||
| [public.agent_message_queue](public.agent_message_queue.md) | 7 | | BASE TABLE |
|
||||
| [public.agent_message_queue](public.agent_message_queue.md) | 9 | | BASE TABLE |
|
||||
| [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE |
|
||||
| [public.agent_task_run_lock](public.agent_task_run_lock.md) | 6 | | BASE TABLE |
|
||||
| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 9 | | BASE TABLE |
|
||||
|
|
@ -129,6 +129,8 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| [public.token_exchange_jti](public.token_exchange_jti.md) | 3 | | BASE TABLE |
|
||||
| [public.trusted_key](public.trusted_key.md) | 4 | | BASE TABLE |
|
||||
| [public.trusted_key_source](public.trusted_key_source.md) | 8 | | BASE TABLE |
|
||||
| [public.trusted_source](public.trusted_source.md) | 12 | | BASE TABLE |
|
||||
| [public.trusted_source_identity](public.trusted_source_identity.md) | 8 | | BASE TABLE |
|
||||
| [public.type_availability_policy](public.type_availability_policy.md) | 7 | | BASE TABLE |
|
||||
| [public.type_availability_policy_attachment](public.type_availability_policy_attachment.md) | 6 | | BASE TABLE |
|
||||
| [public.type_availability_policy_scope](public.type_availability_policy_scope.md) | 8 | | BASE TABLE |
|
||||
|
|
@ -205,7 +207,9 @@ erDiagram
|
|||
"public.agent_files" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
|
||||
"public.agent_history" }o--o| "public.user" : "FOREIGN KEY (#quot;publishedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
|
||||
"public.agent_history" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
|
||||
"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE"
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_message_queue" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
|
||||
"public.agent_task_definition" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
|
||||
"public.agent_task_run_lock" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
|
||||
|
|
@ -349,6 +353,8 @@ erDiagram
|
|||
"public.test_run" }o--o| "public.evaluation_config" : "FOREIGN KEY (#quot;evaluationConfigId#quot;) REFERENCES evaluation_config(id) ON DELETE SET NULL"
|
||||
"public.test_run" }o--o| "public.evaluation_collection" : "FOREIGN KEY (#quot;collectionId#quot;) REFERENCES evaluation_collection(id) ON DELETE SET NULL"
|
||||
"public.trusted_key" }o--|| "public.trusted_key_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_key_source(id) ON DELETE CASCADE"
|
||||
"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE"
|
||||
"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy" : "FOREIGN KEY (#quot;policyId#quot;) REFERENCES type_availability_policy(id) ON DELETE RESTRICT"
|
||||
"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy_scope" : "FOREIGN KEY (#quot;scopeId#quot;) REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE"
|
||||
"public.type_availability_policy_scope" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
|
||||
|
|
@ -529,6 +535,7 @@ erDiagram
|
|||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.agent_execution" {
|
||||
boolean acceptsSteering
|
||||
json attachments
|
||||
json author
|
||||
integer completionTokens
|
||||
|
|
@ -607,8 +614,10 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ executionId FK
|
||||
bigint id
|
||||
varchar_36_ messageId FK
|
||||
json payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
integer steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
|
|
@ -1600,6 +1609,30 @@ erDiagram
|
|||
varchar_32_ type
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.trusted_source" {
|
||||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.trusted_source_identity" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
timestamp_3__with_time_zone lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId FK
|
||||
varchar_16_ status
|
||||
varchar subject
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.type_availability_policy" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ id
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| acceptsSteering | boolean | false | false | | | Accept input until the runtime closes admission |
|
||||
| attachments | json | | true | | | Metadata of files attached to the user turn ({id, fileName, mimeType, sizeBytes}[]); bytes live in BinaryDataService |
|
||||
| author | json | | true | | | Chat platform user who wrote the turn as {id, name}; null for runs outside chat integrations |
|
||||
| completionTokens | integer | | true | | | |
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
| CHK_agent_execution_storedAt | CHECK | CHECK ((("storedAt")::text = ANY ((ARRAY['db'::character varying, 'fs'::character varying, 's3'::character varying, 'az'::character varying])::text[]))) |
|
||||
| FK_add2432fb6034cc18b6af299dce | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES agent_execution_threads(id) ON DELETE CASCADE |
|
||||
| PK_ba438acc8532addc12d1ef17049 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| agent_execution_acceptsSteering_not_null | n | NOT NULL "acceptsSteering" |
|
||||
| agent_execution_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| agent_execution_duration_not_null | n | NOT NULL duration |
|
||||
| agent_execution_id_not_null | n | NOT NULL id |
|
||||
|
|
@ -59,9 +61,11 @@ erDiagram
|
|||
|
||||
"public.agent_execution_message_links" }o--|| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id) ON DELETE CASCADE"
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
|
||||
|
||||
"public.agent_execution" {
|
||||
boolean acceptsSteering
|
||||
json attachments
|
||||
json author
|
||||
integer completionTokens
|
||||
|
|
@ -96,8 +100,10 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ executionId FK
|
||||
bigint id
|
||||
varchar_36_ messageId FK
|
||||
json payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
integer steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ erDiagram
|
|||
integer position
|
||||
}
|
||||
"public.agent_execution" {
|
||||
boolean acceptsSteering
|
||||
json attachments
|
||||
json author
|
||||
integer completionTokens
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ erDiagram
|
|||
varchar_36_ versionId
|
||||
}
|
||||
"public.agent_execution" {
|
||||
boolean acceptsSteering
|
||||
json attachments
|
||||
json author
|
||||
integer completionTokens
|
||||
|
|
@ -134,8 +135,10 @@ erDiagram
|
|||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ executionId FK
|
||||
bigint id
|
||||
varchar_36_ messageId FK
|
||||
json payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
integer steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| executionId | varchar(36) | | true | | [public.agent_execution](public.agent_execution.md) | Current execution; NULL means pending |
|
||||
| id | bigint | | false | | | Acceptance order; IDs are not reused |
|
||||
| payload | json | | false | | | Input, attachment references, identity, and reply context |
|
||||
| source | varchar(32) | | false | | | Preview or integration source |
|
||||
| messageId | varchar(36) | | false | | [public.agents_messages](public.agents_messages.md) | Canonical input created when the queue accepts it |
|
||||
| payload | json | | false | | | Dispatch, authorization, and reply context. Input is stored on the message |
|
||||
| steeringExecutionId | varchar(36) | | true | | [public.agent_execution](public.agent_execution.md) | Execution reserved to consume this input |
|
||||
| steeringOrder | integer | | true | | | Acceptance order among outstanding steers |
|
||||
| threadId | varchar(128) | | false | | [public.agent_execution_threads](public.agent_execution_threads.md) | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
|
||||
|
|
@ -18,11 +20,13 @@
|
|||
| ---- | ---- | ---------- |
|
||||
| FK_2349d84b4f2a660fc264f38fef3 | FOREIGN KEY | FOREIGN KEY ("executionId") REFERENCES agent_execution(id) |
|
||||
| FK_a74f9154a59430986112d70cb16 | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES agent_execution_threads(id) ON DELETE CASCADE |
|
||||
| FK_agent_message_queue_messageId | FOREIGN KEY | FOREIGN KEY ("messageId") REFERENCES agents_messages(id) ON DELETE CASCADE |
|
||||
| FK_eff54787927c2968dc24495c911 | FOREIGN KEY | FOREIGN KEY ("steeringExecutionId") REFERENCES agent_execution(id) |
|
||||
| PK_733d8c959a6057f04f4da5ab721 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| agent_message_queue_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| agent_message_queue_id_not_null | n | NOT NULL id |
|
||||
| agent_message_queue_messageId_not_null | n | NOT NULL "messageId" |
|
||||
| agent_message_queue_payload_not_null | n | NOT NULL payload |
|
||||
| agent_message_queue_source_not_null | n | NOT NULL source |
|
||||
| agent_message_queue_threadId_not_null | n | NOT NULL "threadId" |
|
||||
| agent_message_queue_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
|
||||
|
|
@ -31,6 +35,8 @@
|
|||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_2349d84b4f2a660fc264f38fef | CREATE INDEX "IDX_2349d84b4f2a660fc264f38fef" ON public.agent_message_queue USING btree ("executionId") |
|
||||
| IDX_agent_message_queue_messageId | CREATE UNIQUE INDEX "IDX_agent_message_queue_messageId" ON public.agent_message_queue USING btree ("messageId") |
|
||||
| IDX_agent_message_queue_steeringExecutionId_steeringOrder | CREATE UNIQUE INDEX "IDX_agent_message_queue_steeringExecutionId_steeringOrder" ON public.agent_message_queue USING btree ("steeringExecutionId", "steeringOrder") WHERE ("steeringExecutionId" IS NOT NULL) |
|
||||
| IDX_agent_message_queue_threadId | CREATE UNIQUE INDEX "IDX_agent_message_queue_threadId" ON public.agent_message_queue USING btree ("threadId") WHERE ("executionId" IS NOT NULL) |
|
||||
| IDX_c1db6ea2d031cc49100535e6c6 | CREATE INDEX "IDX_c1db6ea2d031cc49100535e6c6" ON public.agent_message_queue USING btree ("threadId", id) |
|
||||
| PK_733d8c959a6057f04f4da5ab721 | CREATE UNIQUE INDEX "PK_733d8c959a6057f04f4da5ab721" ON public.agent_message_queue USING btree (id) |
|
||||
|
|
@ -41,18 +47,23 @@
|
|||
erDiagram
|
||||
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;executionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE"
|
||||
"public.agent_message_queue" }o--o| "public.agent_execution" : "FOREIGN KEY (#quot;steeringExecutionId#quot;) REFERENCES agent_execution(id)"
|
||||
"public.agent_message_queue" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
|
||||
|
||||
"public.agent_message_queue" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ executionId FK
|
||||
bigint id
|
||||
varchar_36_ messageId FK
|
||||
json payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
integer steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.agent_execution" {
|
||||
boolean acceptsSteering
|
||||
json attachments
|
||||
json author
|
||||
integer completionTokens
|
||||
|
|
@ -76,6 +87,20 @@ erDiagram
|
|||
timestamp_3__with_time_zone updatedAt
|
||||
text userMessage
|
||||
}
|
||||
"public.agents_messages" {
|
||||
json author
|
||||
json content
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ id
|
||||
json modelContent
|
||||
timestamp_3__with_time_zone modelContextAt
|
||||
json origin
|
||||
varchar_255_ resourceId
|
||||
varchar_36_ role
|
||||
varchar_255_ threadId FK
|
||||
varchar_36_ type
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.agent_execution_threads" {
|
||||
varchar_16_ accessScope
|
||||
varchar_36_ agentId FK
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| author | json | | true | | | Original message author supplied by the chat platform |
|
||||
| content | json | | false | | | |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| id | varchar(36) | | false | [public.agent_execution_message_links](public.agent_execution_message_links.md) [public.agents_memory_entry_candidates](public.agents_memory_entry_candidates.md) | | |
|
||||
| id | varchar(36) | | false | [public.agent_execution_message_links](public.agent_execution_message_links.md) [public.agent_message_queue](public.agent_message_queue.md) [public.agents_memory_entry_candidates](public.agents_memory_entry_candidates.md) | | |
|
||||
| modelContent | json | | true | | | Enriched model input when it differs from the original content |
|
||||
| modelContextAt | timestamp(3) with time zone | | true | | | Runtime ordering timestamp. NULL inputs are excluded from model context |
|
||||
| origin | json | | true | | | Source identifiers and transcript visibility. NULL for legacy or SDK-only messages |
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
erDiagram
|
||||
|
||||
"public.agent_execution_message_links" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE"
|
||||
"public.agent_message_queue" }o--|| "public.agents_messages" : "FOREIGN KEY (#quot;messageId#quot;) REFERENCES agents_messages(id) ON DELETE CASCADE"
|
||||
"public.agents_memory_entry_candidates" }o--o| "public.agents_messages" : "FOREIGN KEY (#quot;sourceMessageId#quot;) REFERENCES agents_messages(id) ON DELETE SET NULL"
|
||||
"public.agents_messages" }o--|| "public.agents_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agents_threads(id) ON DELETE CASCADE"
|
||||
|
||||
|
|
@ -71,6 +72,17 @@ erDiagram
|
|||
varchar_36_ messageId FK
|
||||
integer position
|
||||
}
|
||||
"public.agent_message_queue" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ executionId FK
|
||||
bigint id
|
||||
varchar_36_ messageId FK
|
||||
json payload
|
||||
varchar_36_ steeringExecutionId FK
|
||||
integer steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.agents_memory_entry_candidates" {
|
||||
varchar_36_ agentId FK
|
||||
smallint attemptCount
|
||||
|
|
|
|||
79
docs/generated/postgres-schema/public.trusted_source.md
generated
Normal file
79
docs/generated/postgres-schema/public.trusted_source.md
generated
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# public.trusted_source
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| config | text | | false | | | |
|
||||
| configVersion | integer | | false | | | |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| id | varchar(36) | | false | [public.trusted_source_identity](public.trusted_source_identity.md) | | |
|
||||
| issuer | varchar | | false | | | |
|
||||
| lastCheckedAt | timestamp(3) with time zone | | true | | | |
|
||||
| lastError | text | | true | | | |
|
||||
| managedBy | varchar(16) | | false | | | |
|
||||
| name | varchar(128) | | false | | | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| type | varchar(32) | | false | | | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| PK_2eea0a99b54a2022620817b1ffb | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| trusted_source_configVersion_not_null | n | NOT NULL "configVersion" |
|
||||
| trusted_source_config_not_null | n | NOT NULL config |
|
||||
| trusted_source_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| trusted_source_id_not_null | n | NOT NULL id |
|
||||
| trusted_source_issuer_not_null | n | NOT NULL issuer |
|
||||
| trusted_source_managedBy_not_null | n | NOT NULL "managedBy" |
|
||||
| trusted_source_name_not_null | n | NOT NULL name |
|
||||
| trusted_source_status_not_null | n | NOT NULL status |
|
||||
| trusted_source_type_not_null | n | NOT NULL type |
|
||||
| trusted_source_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_519e3445a1740f2ddbab2749b5 | CREATE UNIQUE INDEX "IDX_519e3445a1740f2ddbab2749b5" ON public.trusted_source USING btree (issuer) |
|
||||
| IDX_d3055524099cd341a2af4b8eb9 | CREATE UNIQUE INDEX "IDX_d3055524099cd341a2af4b8eb9" ON public.trusted_source USING btree (name) |
|
||||
| PK_2eea0a99b54a2022620817b1ffb | CREATE UNIQUE INDEX "PK_2eea0a99b54a2022620817b1ffb" ON public.trusted_source USING btree (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE"
|
||||
|
||||
"public.trusted_source" {
|
||||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.trusted_source_identity" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
timestamp_3__with_time_zone lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId FK
|
||||
varchar_16_ status
|
||||
varchar subject
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
91
docs/generated/postgres-schema/public.trusted_source_identity.md
generated
Normal file
91
docs/generated/postgres-schema/public.trusted_source_identity.md
generated
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# public.trusted_source_identity
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| lastSeenAt | timestamp(3) with time zone | | true | | | |
|
||||
| provenance | varchar(32) | | false | | | |
|
||||
| sourceId | varchar(36) | | false | | [public.trusted_source](public.trusted_source.md) | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| subject | varchar | | false | | | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| userId | uuid | | false | | [public.user](public.user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| PK_aaeab0a64d04b4c5620140f0221 | PRIMARY KEY | PRIMARY KEY ("sourceId", subject) |
|
||||
| trusted_source_identity_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| trusted_source_identity_provenance_not_null | n | NOT NULL provenance |
|
||||
| trusted_source_identity_sourceId_foreign | FOREIGN KEY | FOREIGN KEY ("sourceId") REFERENCES trusted_source(id) ON DELETE CASCADE |
|
||||
| trusted_source_identity_sourceId_not_null | n | NOT NULL "sourceId" |
|
||||
| trusted_source_identity_status_not_null | n | NOT NULL status |
|
||||
| trusted_source_identity_subject_not_null | n | NOT NULL subject |
|
||||
| trusted_source_identity_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
| trusted_source_identity_userId_foreign | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE CASCADE |
|
||||
| trusted_source_identity_userId_not_null | n | NOT NULL "userId" |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_bcc1e89f2ab58755614b601168 | CREATE INDEX "IDX_bcc1e89f2ab58755614b601168" ON public.trusted_source_identity USING btree ("userId") |
|
||||
| PK_aaeab0a64d04b4c5620140f0221 | CREATE UNIQUE INDEX "PK_aaeab0a64d04b4c5620140f0221" ON public.trusted_source_identity USING btree ("sourceId", subject) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"public.trusted_source_identity" }o--|| "public.trusted_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_source(id) ON DELETE CASCADE"
|
||||
"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
|
||||
"public.trusted_source_identity" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
timestamp_3__with_time_zone lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId FK
|
||||
varchar_16_ status
|
||||
varchar subject
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.trusted_source" {
|
||||
text config
|
||||
integer configVersion
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_36_ id
|
||||
varchar issuer
|
||||
timestamp_3__with_time_zone lastCheckedAt
|
||||
text lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.user" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
uuid id
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
text mfaRecoveryCodes
|
||||
text mfaSecret
|
||||
varchar_255_ password
|
||||
json personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
json settings
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
13
docs/generated/postgres-schema/public.user.md
generated
13
docs/generated/postgres-schema/public.user.md
generated
|
|
@ -8,7 +8,7 @@
|
|||
| disabled | boolean | false | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | false | false | | | |
|
||||
|
|
@ -71,6 +71,7 @@ erDiagram
|
|||
"public.oauth_user_consents" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.project" }o--o| "public.user" : "FOREIGN KEY (#quot;creatorId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
|
||||
"public.project_relation" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.trusted_source_identity" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.user_api_keys" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.user_favorites" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.workflow_builder_session" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
|
|
@ -353,6 +354,16 @@ erDiagram
|
|||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.trusted_source_identity" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
timestamp_3__with_time_zone lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId FK
|
||||
varchar_16_ status
|
||||
varchar subject
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.user_api_keys" {
|
||||
varchar apiKey
|
||||
varchar audience
|
||||
|
|
|
|||
41
docs/generated/sqlite-schema/README.md
generated
41
docs/generated/sqlite-schema/README.md
generated
|
|
@ -19,12 +19,12 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [agent_eval_rating](agent_eval_rating.md) | 8 | | table |
|
||||
| [agent_eval_result](agent_eval_result.md) | 15 | | table |
|
||||
| [agent_eval_run](agent_eval_run.md) | 14 | | table |
|
||||
| [agent_execution](agent_execution.md) | 22 | | table |
|
||||
| [agent_execution](agent_execution.md) | 23 | | table |
|
||||
| [agent_execution_message_links](agent_execution_message_links.md) | 5 | | table |
|
||||
| [agent_execution_threads](agent_execution_threads.md) | 19 | | table |
|
||||
| [agent_files](agent_files.md) | 10 | | table |
|
||||
| [agent_history](agent_history.md) | 9 | | table |
|
||||
| [agent_message_queue](agent_message_queue.md) | 7 | | table |
|
||||
| [agent_message_queue](agent_message_queue.md) | 9 | | table |
|
||||
| [agent_task_definition](agent_task_definition.md) | 8 | | table |
|
||||
| [agent_task_run_lock](agent_task_run_lock.md) | 6 | | table |
|
||||
| [agent_task_snapshot](agent_task_snapshot.md) | 9 | | table |
|
||||
|
|
@ -129,6 +129,8 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [token_exchange_jti](token_exchange_jti.md) | 3 | | table |
|
||||
| [trusted_key](trusted_key.md) | 4 | | table |
|
||||
| [trusted_key_source](trusted_key_source.md) | 8 | | table |
|
||||
| [trusted_source](trusted_source.md) | 12 | | table |
|
||||
| [trusted_source_identity](trusted_source_identity.md) | 8 | | table |
|
||||
| [type_availability_policy](type_availability_policy.md) | 7 | | table |
|
||||
| [type_availability_policy_attachment](type_availability_policy_attachment.md) | 6 | | table |
|
||||
| [type_availability_policy_scope](type_availability_policy_scope.md) | 8 | | table |
|
||||
|
|
@ -187,8 +189,10 @@ erDiagram
|
|||
"agent_files" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_history" }o--o| "user" : "FOREIGN KEY (publishedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"agent_history" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_task_definition" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_task_run_lock" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_task_snapshot" |o--|| "agent_history" : "FOREIGN KEY (versionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -333,6 +337,8 @@ erDiagram
|
|||
"test_run" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"test_run" }o--o| "evaluation_config" : "FOREIGN KEY (evaluationConfigId) REFERENCES evaluation_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"trusted_key" |o--|| "trusted_key_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_key_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"type_availability_policy_attachment" |o--|| "type_availability_policy" : "FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
|
||||
"type_availability_policy_attachment" |o--|| "type_availability_policy_scope" : "FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"type_availability_policy_scope" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -515,6 +521,7 @@ erDiagram
|
|||
datetime_3_ updatedAt
|
||||
}
|
||||
"agent_execution" {
|
||||
BOOLEAN acceptsSteering
|
||||
TEXT attachments
|
||||
TEXT author
|
||||
INTEGER completionTokens
|
||||
|
|
@ -593,8 +600,10 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
varchar_36_ executionId FK
|
||||
INTEGER id
|
||||
varchar_36_ messageId FK
|
||||
TEXT payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
INTEGER steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
|
|
@ -1588,6 +1597,30 @@ erDiagram
|
|||
varchar_32_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"trusted_source" {
|
||||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"trusted_source_identity" {
|
||||
datetime_3_ createdAt
|
||||
datetime_3_ lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId PK
|
||||
varchar_16_ status
|
||||
varchar subject PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"type_availability_policy" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
|
|
|
|||
9
docs/generated/sqlite-schema/agent_execution.md
generated
9
docs/generated/sqlite-schema/agent_execution.md
generated
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
|
||||
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, "author" text, "acceptsSteering" BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -15,6 +15,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
|
|||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| acceptsSteering | BOOLEAN | FALSE | false | | | |
|
||||
| attachments | TEXT | | true | | | |
|
||||
| author | TEXT | | true | | | |
|
||||
| completionTokens | INTEGER | | true | | | |
|
||||
|
|
@ -63,10 +64,12 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
|
|||
erDiagram
|
||||
|
||||
"agent_execution_message_links" |o--|| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"agent_execution" {
|
||||
BOOLEAN acceptsSteering
|
||||
TEXT attachments
|
||||
TEXT author
|
||||
INTEGER completionTokens
|
||||
|
|
@ -101,8 +104,10 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
varchar_36_ executionId FK
|
||||
INTEGER id
|
||||
varchar_36_ messageId FK
|
||||
TEXT payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
INTEGER steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ erDiagram
|
|||
INTEGER position
|
||||
}
|
||||
"agent_execution" {
|
||||
BOOLEAN acceptsSteering
|
||||
TEXT attachments
|
||||
TEXT author
|
||||
INTEGER completionTokens
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ erDiagram
|
|||
varchar_36_ versionId
|
||||
}
|
||||
"agent_execution" {
|
||||
BOOLEAN acceptsSteering
|
||||
TEXT attachments
|
||||
TEXT author
|
||||
INTEGER completionTokens
|
||||
|
|
@ -134,8 +135,10 @@ erDiagram
|
|||
datetime_3_ createdAt
|
||||
varchar_36_ executionId FK
|
||||
INTEGER id
|
||||
varchar_36_ messageId FK
|
||||
TEXT payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
INTEGER steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
|
|
|
|||
35
docs/generated/sqlite-schema/agent_message_queue.md
generated
35
docs/generated/sqlite-schema/agent_message_queue.md
generated
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "threadId" varchar(128) NOT NULL, "source" varchar(32) NOT NULL, "payload" text NOT NULL, "executionId" varchar(36), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_a74f9154a59430986112d70cb16" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE, CONSTRAINT "FK_2349d84b4f2a660fc264f38fef3" FOREIGN KEY ("executionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION)
|
||||
CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "threadId" varchar(128) NOT NULL, "payload" text NOT NULL, "executionId" varchar(36), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "messageId" varchar(36) NOT NULL, "steeringExecutionId" varchar(36), "steeringOrder" integer, CONSTRAINT "FK_2349d84b4f2a660fc264f38fef3" FOREIGN KEY ("executionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a74f9154a59430986112d70cb16" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_agent_message_queue_messageId" FOREIGN KEY ("messageId") REFERENCES "agents_messages" ("id") ON DELETE CASCADE, CONSTRAINT "FK_eff54787927c2968dc24495c911" FOREIGN KEY ("steeringExecutionId") REFERENCES "agent_execution" ("id") ON DELETE NO ACTION)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -18,8 +18,10 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N
|
|||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| executionId | varchar(36) | | true | | [agent_execution](agent_execution.md) | |
|
||||
| id | INTEGER | | false | | | |
|
||||
| messageId | varchar(36) | | false | | [agents_messages](agents_messages.md) | |
|
||||
| payload | TEXT | | false | | | |
|
||||
| source | varchar(32) | | false | | | |
|
||||
| steeringExecutionId | varchar(36) | | true | | [agent_execution](agent_execution.md) | |
|
||||
| steeringOrder | INTEGER | | true | | | |
|
||||
| threadId | varchar(128) | | false | | [agent_execution_threads](agent_execution_threads.md) | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
||||
|
|
@ -27,8 +29,10 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 3) | FOREIGN KEY | FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
|
@ -36,6 +40,8 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N
|
|||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_2349d84b4f2a660fc264f38fef | CREATE INDEX "IDX_2349d84b4f2a660fc264f38fef" ON "agent_message_queue" ("executionId") |
|
||||
| IDX_agent_message_queue_messageId | CREATE UNIQUE INDEX "IDX_agent_message_queue_messageId" ON "agent_message_queue" ("messageId") |
|
||||
| IDX_agent_message_queue_steeringExecutionId_steeringOrder | CREATE UNIQUE INDEX "IDX_agent_message_queue_steeringExecutionId_steeringOrder" ON "agent_message_queue" ("steeringExecutionId", "steeringOrder") WHERE "steeringExecutionId" IS NOT NULL |
|
||||
| IDX_agent_message_queue_threadId | CREATE UNIQUE INDEX "IDX_agent_message_queue_threadId" ON "agent_message_queue" ("threadId") WHERE "executionId" IS NOT NULL |
|
||||
| IDX_c1db6ea2d031cc49100535e6c6 | CREATE INDEX "IDX_c1db6ea2d031cc49100535e6c6" ON "agent_message_queue" ("threadId", "id") |
|
||||
|
||||
|
|
@ -45,18 +51,23 @@ CREATE TABLE "agent_message_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT N
|
|||
erDiagram
|
||||
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (executionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--o| "agent_execution" : "FOREIGN KEY (steeringExecutionId) REFERENCES agent_execution (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
|
||||
"agent_message_queue" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"agent_message_queue" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ executionId FK
|
||||
INTEGER id
|
||||
varchar_36_ messageId FK
|
||||
TEXT payload
|
||||
varchar_32_ source
|
||||
varchar_36_ steeringExecutionId FK
|
||||
INTEGER steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"agent_execution" {
|
||||
BOOLEAN acceptsSteering
|
||||
TEXT attachments
|
||||
TEXT author
|
||||
INTEGER completionTokens
|
||||
|
|
@ -80,6 +91,20 @@ erDiagram
|
|||
datetime_3_ updatedAt
|
||||
TEXT userMessage
|
||||
}
|
||||
"agents_messages" {
|
||||
TEXT author
|
||||
TEXT content
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
TEXT modelContent
|
||||
DATETIME modelContextAt
|
||||
TEXT origin
|
||||
varchar_255_ resourceId
|
||||
varchar_36_ role
|
||||
varchar_255_ threadId FK
|
||||
varchar_36_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"agent_execution_threads" {
|
||||
varchar_16_ accessScope
|
||||
varchar_36_ agentId FK
|
||||
|
|
|
|||
14
docs/generated/sqlite-schema/agents_messages.md
generated
14
docs/generated/sqlite-schema/agents_messages.md
generated
|
|
@ -18,7 +18,7 @@ CREATE TABLE "agents_messages" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
|
|||
| author | TEXT | | true | | | |
|
||||
| content | TEXT | | false | | | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| id | varchar(36) | | false | [agent_execution_message_links](agent_execution_message_links.md) [agents_memory_entry_candidates](agents_memory_entry_candidates.md) | | |
|
||||
| id | varchar(36) | | false | [agent_execution_message_links](agent_execution_message_links.md) [agent_message_queue](agent_message_queue.md) [agents_memory_entry_candidates](agents_memory_entry_candidates.md) | | |
|
||||
| modelContent | TEXT | | true | | | |
|
||||
| modelContextAt | DATETIME | | true | | | |
|
||||
| origin | TEXT | | true | | | |
|
||||
|
|
@ -52,6 +52,7 @@ CREATE TABLE "agents_messages" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
|
|||
erDiagram
|
||||
|
||||
"agent_execution_message_links" |o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agent_message_queue" }o--|| "agents_messages" : "FOREIGN KEY (messageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"agents_memory_entry_candidates" }o--o| "agents_messages" : "FOREIGN KEY (sourceMessageId) REFERENCES agents_messages (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"agents_messages" }o--|| "agents_threads" : "FOREIGN KEY (threadId) REFERENCES agents_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
|
|
@ -76,6 +77,17 @@ erDiagram
|
|||
varchar_36_ messageId PK
|
||||
INTEGER position
|
||||
}
|
||||
"agent_message_queue" {
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ executionId FK
|
||||
INTEGER id
|
||||
varchar_36_ messageId FK
|
||||
TEXT payload
|
||||
varchar_36_ steeringExecutionId FK
|
||||
INTEGER steeringOrder
|
||||
varchar_128_ threadId FK
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"agents_memory_entry_candidates" {
|
||||
varchar_36_ agentId FK
|
||||
smallint attemptCount
|
||||
|
|
|
|||
81
docs/generated/sqlite-schema/trusted_source.md
generated
Normal file
81
docs/generated/sqlite-schema/trusted_source.md
generated
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# trusted_source
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "trusted_source" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "type" varchar(32) NOT NULL, "issuer" varchar NOT NULL, "managedBy" varchar(16) NOT NULL, "status" varchar(16) NOT NULL, "lastError" text, "lastCheckedAt" datetime(3), "configVersion" integer NOT NULL, "config" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| config | TEXT | | false | | | |
|
||||
| configVersion | INTEGER | | false | | | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| id | varchar(36) | | false | [trusted_source_identity](trusted_source_identity.md) | | |
|
||||
| issuer | varchar | | false | | | |
|
||||
| lastCheckedAt | datetime(3) | | true | | | |
|
||||
| lastError | TEXT | | true | | | |
|
||||
| managedBy | varchar(16) | | false | | | |
|
||||
| name | varchar(128) | | false | | | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| type | varchar(32) | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| sqlite_autoindex_trusted_source_1 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_519e3445a1740f2ddbab2749b5 | CREATE UNIQUE INDEX "IDX_519e3445a1740f2ddbab2749b5" ON "trusted_source" ("issuer") |
|
||||
| IDX_d3055524099cd341a2af4b8eb9 | CREATE UNIQUE INDEX "IDX_d3055524099cd341a2af4b8eb9" ON "trusted_source" ("name") |
|
||||
| sqlite_autoindex_trusted_source_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"trusted_source" {
|
||||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"trusted_source_identity" {
|
||||
datetime_3_ createdAt
|
||||
datetime_3_ lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId PK
|
||||
varchar_16_ status
|
||||
varchar subject PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
97
docs/generated/sqlite-schema/trusted_source_identity.md
generated
Normal file
97
docs/generated/sqlite-schema/trusted_source_identity.md
generated
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# trusted_source_identity
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "trusted_source_identity" ("sourceId" varchar(36) NOT NULL, "subject" varchar NOT NULL, "userId" varchar NOT NULL, "provenance" varchar(32) NOT NULL, "status" varchar(16) NOT NULL, "lastSeenAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "trusted_source_identity_sourceId_foreign" FOREIGN KEY ("sourceId") REFERENCES "trusted_source" ("id") ON DELETE CASCADE, CONSTRAINT "trusted_source_identity_userId_foreign" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, PRIMARY KEY ("sourceId", "subject"))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| lastSeenAt | datetime(3) | | true | | | |
|
||||
| provenance | varchar(32) | | false | | | |
|
||||
| sourceId | varchar(36) | | false | | [trusted_source](trusted_source.md) | |
|
||||
| status | varchar(16) | | false | | | |
|
||||
| subject | varchar | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| userId | varchar | | false | | [user](user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| sourceId | PRIMARY KEY | PRIMARY KEY (sourceId) |
|
||||
| sqlite_autoindex_trusted_source_identity_1 | PRIMARY KEY | PRIMARY KEY (sourceId, subject) |
|
||||
| subject | PRIMARY KEY | PRIMARY KEY (subject) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_bcc1e89f2ab58755614b601168 | CREATE INDEX "IDX_bcc1e89f2ab58755614b601168" ON "trusted_source_identity" ("userId") |
|
||||
| sqlite_autoindex_trusted_source_identity_1 | PRIMARY KEY (sourceId, subject) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"trusted_source_identity" |o--|| "trusted_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"trusted_source_identity" {
|
||||
datetime_3_ createdAt
|
||||
datetime_3_ lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId PK
|
||||
varchar_16_ status
|
||||
varchar subject PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"trusted_source" {
|
||||
TEXT config
|
||||
INTEGER configVersion
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ id PK
|
||||
varchar issuer
|
||||
datetime_3_ lastCheckedAt
|
||||
TEXT lastError
|
||||
varchar_16_ managedBy
|
||||
varchar_128_ name
|
||||
varchar_16_ status
|
||||
varchar_32_ type
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"user" {
|
||||
datetime_3_ createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
varchar id PK
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
TEXT mfaRecoveryCodes
|
||||
TEXT mfaSecret
|
||||
varchar password
|
||||
TEXT personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
TEXT settings
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
13
docs/generated/sqlite-schema/user.md
generated
13
docs/generated/sqlite-schema/user.md
generated
|
|
@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
|
|||
| disabled | boolean | FALSE | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | |
|
||||
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | FALSE | false | | | |
|
||||
|
|
@ -77,6 +77,7 @@ erDiagram
|
|||
"oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"project_relation" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"trusted_source_identity" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"user_api_keys" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"user_favorites" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"workflow_builder_session" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
|
@ -359,6 +360,16 @@ erDiagram
|
|||
datetime_3_ updatedAt
|
||||
varchar userId PK
|
||||
}
|
||||
"trusted_source_identity" {
|
||||
datetime_3_ createdAt
|
||||
datetime_3_ lastSeenAt
|
||||
varchar_32_ provenance
|
||||
varchar_36_ sourceId PK
|
||||
varchar_16_ status
|
||||
varchar subject PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"user_api_keys" {
|
||||
varchar apiKey
|
||||
varchar audience
|
||||
|
|
|
|||
4
knip.ts
4
knip.ts
|
|
@ -93,6 +93,10 @@ const config: KnipConfig = {
|
|||
'@types/psl',
|
||||
],
|
||||
}),
|
||||
'packages/@n8n/backend-services': pkg({
|
||||
// ioredis-mock is loaded with vi.importActual, which knip does not link.
|
||||
ignoreDependencies: ['ioredis-mock'],
|
||||
}),
|
||||
'packages/@n8n/instance-ai': pkg({
|
||||
// psl is loaded with a dynamic import, which knip does not link to @types.
|
||||
ignoreDependencies: ['@types/psl'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.41.0",
|
||||
"version": "2.42.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
@ -54,6 +54,9 @@
|
|||
"agent:test": "node scripts/agent-check.mjs test",
|
||||
"agent:playwright": "node scripts/agent-check.mjs playwright",
|
||||
"agent:harness": "node scripts/agent-harness.mjs",
|
||||
"profile:vitest": "node scripts/vitest-profile.mjs",
|
||||
"profile:ci-tests": "node scripts/ci-test-phase-profile.mjs",
|
||||
"test:inventory": "node scripts/test-inventory.mjs",
|
||||
"format:check": "turbo run format:check",
|
||||
"lint": "turbo run lint",
|
||||
"lint:styles": "turbo run lint:styles",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export type {
|
|||
EpisodicMemoryMethods,
|
||||
EpisodicMemoryPrompts,
|
||||
EpisodicMemoryReflectFn,
|
||||
EpisodicMemoryReflectResult,
|
||||
EpisodicMemoryReflection,
|
||||
EpisodicMemoryReflectionApply,
|
||||
EpisodicMemoryReflectionApplyMerge,
|
||||
|
|
@ -75,9 +76,12 @@ export type {
|
|||
McpVerifyResult,
|
||||
ModelConfig,
|
||||
ExecutionOptions,
|
||||
AgentInputBoundary,
|
||||
SmoothStreamOptions,
|
||||
TokenUsage,
|
||||
AgentExecutionCounter,
|
||||
SideCallTask,
|
||||
SideCallUsageReport,
|
||||
PersistedExecutionOptions,
|
||||
AnthropicPromptCachingConfig,
|
||||
OpenAIPromptCachingConfig,
|
||||
|
|
@ -93,8 +97,10 @@ export type {
|
|||
ObservationLogEntry,
|
||||
ObservationLogMarker,
|
||||
ObservationLogMerge,
|
||||
ObservationLogObserveResult,
|
||||
ObservationLogReadOptions,
|
||||
ObservationLogReflection,
|
||||
ObservationLogReflectResult,
|
||||
ObservationLogReflectionResult,
|
||||
ObservationLogScope,
|
||||
ObservationLogStatus,
|
||||
|
|
@ -369,6 +375,7 @@ export {
|
|||
export type {
|
||||
RunEpisodicMemoryCandidateProcessorOpts,
|
||||
RunEpisodicMemoryCandidateProcessorResult,
|
||||
EpisodicMemoryUsageReport,
|
||||
} from './runtime/memory/episodic-memory-capture';
|
||||
export {
|
||||
DEFAULT_EPISODIC_MEMORY_CAPTURE_TOOL_INSTRUCTION,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type { RuntimeSkillSource } from '../../skills/types';
|
|||
import type { CheckpointStore, ModelConfig, SerializableAgentState } from '../../types';
|
||||
import { AgentEvent } from '../../types/runtime/event';
|
||||
import type { AgentEventData } from '../../types/runtime/event';
|
||||
import type { StreamChunk } from '../../types/sdk/agent';
|
||||
import type { ExecutionOptions, StreamChunk } from '../../types/sdk/agent';
|
||||
import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message';
|
||||
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool';
|
||||
import type { BuiltTelemetry } from '../../types/telemetry';
|
||||
|
|
@ -173,7 +173,9 @@ async function collectChunks(stream: ReadableStream<unknown>): Promise<StreamChu
|
|||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value as StreamChunk);
|
||||
const chunk = value as StreamChunk;
|
||||
chunks.push(chunk);
|
||||
if (chunk.type === 'input-boundary') chunk.acknowledge();
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
|
@ -409,13 +411,13 @@ describe('AgentRuntime — rejected attachments', () => {
|
|||
runtime: AgentRuntime,
|
||||
mode: 'generate' | 'stream',
|
||||
messages: Message[] | string = [structuredClone(input)],
|
||||
abortSignal?: AbortSignal,
|
||||
options: Pick<ExecutionOptions, 'abortSignal' | 'onInputBoundary'> = {},
|
||||
) {
|
||||
if (mode === 'generate') {
|
||||
const result = await runtime.generate(messages, { persistence, abortSignal });
|
||||
const result = await runtime.generate(messages, { persistence, ...options });
|
||||
return result.error;
|
||||
}
|
||||
const result = await runtime.stream(messages, { persistence, abortSignal });
|
||||
const result = await runtime.stream(messages, { persistence, ...options });
|
||||
const chunks = await collectChunks(result.stream);
|
||||
return chunks.find((chunk) => chunk.type === 'error');
|
||||
}
|
||||
|
|
@ -608,7 +610,9 @@ describe('AgentRuntime — rejected attachments', () => {
|
|||
await deleteMessages(ids);
|
||||
});
|
||||
rejectModel();
|
||||
await run(buildRuntime(memory), mode, [structuredClone(input)], controller.signal);
|
||||
await run(buildRuntime(memory), mode, [structuredClone(input)], {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
expect(await memory.getMessages(persistence.threadId)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
|
@ -637,37 +641,79 @@ describe('AgentRuntime — rejected attachments', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(['generate', 'stream'] as const)('%s preserves input after a tool call', async (mode) => {
|
||||
const memory = new InMemoryMemory();
|
||||
const remove = vi.spyOn(memory, 'deleteMessages');
|
||||
const handler = vi.fn().mockResolvedValue('Done');
|
||||
const tool: BuiltTool = {
|
||||
name: 'search',
|
||||
description: 'Search',
|
||||
inputSchema: z.object({}),
|
||||
handler,
|
||||
};
|
||||
const response = makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'search-1', toolName: 'search', args: {} },
|
||||
]);
|
||||
generateText.mockResolvedValueOnce(response);
|
||||
streamText.mockReturnValueOnce({
|
||||
...makeStreamSuccess(),
|
||||
stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
response: Promise.resolve(response.response),
|
||||
toolCalls: Promise.resolve(response.toolCalls),
|
||||
});
|
||||
rejectModel();
|
||||
await run(buildRuntime(memory, [tool]), mode);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(await memory.getMessages(persistence.threadId)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: input.id })]),
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
{ mode: 'generate', steer: false },
|
||||
{ mode: 'generate', steer: true },
|
||||
{ mode: 'stream', steer: false },
|
||||
{ mode: 'stream', steer: true },
|
||||
] as const)(
|
||||
'$mode preserves accepted input after a tool call (steer: $steer)',
|
||||
async ({ mode, steer }) => {
|
||||
const memory = new InMemoryMemory();
|
||||
const remove = vi.spyOn(memory, 'deleteMessages');
|
||||
const steered: AgentDbMessage = {
|
||||
...structuredClone(input),
|
||||
id: 'steered-input',
|
||||
createdAt: new Date(Date.now() + 100),
|
||||
};
|
||||
steered.content[0] = { type: 'text', text: 'Steered image' };
|
||||
const handler = vi.fn().mockResolvedValue('Done');
|
||||
const tool: BuiltTool = {
|
||||
name: 'search',
|
||||
description: 'Search',
|
||||
inputSchema: z.object({}),
|
||||
handler,
|
||||
};
|
||||
const response = makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'search-1', toolName: 'search', args: {} },
|
||||
]);
|
||||
generateText.mockResolvedValueOnce(response);
|
||||
streamText.mockReturnValueOnce({
|
||||
...makeStreamSuccess(),
|
||||
stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
response: Promise.resolve(response.response),
|
||||
toolCalls: Promise.resolve(response.toolCalls),
|
||||
});
|
||||
rejectModel();
|
||||
const onInputBoundary = vi.fn(async () => {
|
||||
if (!steer || handler.mock.calls.length === 0) return [];
|
||||
await memory.saveMessages({ ...persistence, messages: [steered] });
|
||||
return [steered];
|
||||
});
|
||||
const runtime = buildRuntime(memory, [tool]);
|
||||
expect(await run(runtime, mode, [structuredClone(input)], { onInputBoundary })).toBeDefined();
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
if (steer) {
|
||||
expect(remove).toHaveBeenCalledExactlyOnceWith([steered.id]);
|
||||
expect(runtime.getState().messageList.inputIds).toEqual([input.id]);
|
||||
} else expect(remove).not.toHaveBeenCalled();
|
||||
expect(await memory.getMessages(persistence.threadId)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: input.id })]),
|
||||
);
|
||||
expect(await memory.getMessages(persistence.threadId)).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: steered.id })]),
|
||||
);
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
streamText.mockReturnValueOnce(makeStreamSuccess());
|
||||
expect(await run(buildRuntime(memory), mode, 'Continue')).toBeUndefined();
|
||||
const model = mode === 'generate' ? generateText : streamText;
|
||||
expect(model.mock.calls[2][0].messages).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.arrayContaining([{ type: 'text', text: 'Steered image' }]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['generate', 'stream'] as const)('%s leaves resumed input unchanged', async (mode) => {
|
||||
it.each([
|
||||
{ mode: 'generate', steer: false },
|
||||
{ mode: 'generate', steer: true },
|
||||
{ mode: 'stream', steer: false },
|
||||
{ mode: 'stream', steer: true },
|
||||
] as const)('$mode preserves resumed input (steer: $steer)', async ({ mode, steer }) => {
|
||||
const memory = new InMemoryMemory();
|
||||
const remove = vi.spyOn(memory, 'deleteMessages');
|
||||
const runtime = buildRuntime(memory, [makeInterruptibleTool()]);
|
||||
|
|
@ -678,20 +724,265 @@ describe('AgentRuntime — rejected attachments', () => {
|
|||
);
|
||||
const first = await runtime.generate([structuredClone(input)], { persistence });
|
||||
const { runId, toolCallId } = first.pendingSuspend![0];
|
||||
const steered: AgentDbMessage = {
|
||||
...structuredClone(input),
|
||||
id: 'steered-input',
|
||||
createdAt: new Date(Date.now() + 100),
|
||||
};
|
||||
const onInputBoundary = async () => {
|
||||
if (!steer) return [];
|
||||
await memory.saveMessages({ ...persistence, messages: [steered] });
|
||||
return [steered];
|
||||
};
|
||||
rejectModel();
|
||||
if (mode === 'generate') {
|
||||
await runtime.resume('generate', { approved: true }, { runId, toolCallId });
|
||||
await runtime.resume('generate', { approved: true }, { runId, toolCallId, onInputBoundary });
|
||||
} else {
|
||||
const resumed = await runtime.resume('stream', { approved: true }, { runId, toolCallId });
|
||||
const resumed = await runtime.resume(
|
||||
'stream',
|
||||
{ approved: true },
|
||||
{ runId, toolCallId, onInputBoundary },
|
||||
);
|
||||
await collectChunks(resumed.stream);
|
||||
}
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
if (steer) expect(remove).toHaveBeenCalledExactlyOnceWith([steered.id]);
|
||||
else expect(remove).not.toHaveBeenCalled();
|
||||
expect(runtime.getState().messageList.inputIds).toEqual([input.id]);
|
||||
expect(await memory.getMessages(persistence.threadId)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: input.id })]),
|
||||
);
|
||||
expect(await memory.getMessages(persistence.threadId)).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: steered.id })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentRuntime — additional input', () => {
|
||||
beforeEach(() => {
|
||||
generateText.mockReset();
|
||||
streamText.mockReset();
|
||||
});
|
||||
|
||||
function input(text: string): AgentDbMessage {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
createdAt: new Date(Date.now() + 100),
|
||||
};
|
||||
}
|
||||
|
||||
it('waits for the complete tool batch before applying input in order', async () => {
|
||||
const started = createDeferredPromise();
|
||||
const release = createDeferredPromise();
|
||||
const handler = vi.fn(async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return 'tool result';
|
||||
});
|
||||
const tool: BuiltTool = {
|
||||
name: 'search',
|
||||
description: 'Search',
|
||||
inputSchema: z.object({}),
|
||||
handler,
|
||||
};
|
||||
const response = makeGenerateWithToolCall('search-1', 'search', {});
|
||||
streamText
|
||||
.mockReturnValueOnce({
|
||||
...makeStreamSuccess(),
|
||||
stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
response: Promise.resolve(response.response),
|
||||
toolCalls: Promise.resolve(response.toolCalls),
|
||||
})
|
||||
.mockReturnValue(makeStreamSuccess('after'));
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'Test',
|
||||
tools: [tool],
|
||||
eventBus: new AgentEventBus(),
|
||||
});
|
||||
const pending = [input('C'), input('D')];
|
||||
let available = false;
|
||||
const onInputBoundary = vi.fn(async () => {
|
||||
if (!available) return [];
|
||||
available = false;
|
||||
return pending;
|
||||
});
|
||||
const counter = makeExecutionCounter();
|
||||
const result = await runtime.stream('A', { onInputBoundary, executionCounter: counter });
|
||||
const chunksPromise = collectChunks(result.stream);
|
||||
await started.promise;
|
||||
available = true;
|
||||
expect(onInputBoundary).toHaveBeenCalledTimes(1);
|
||||
expect(streamText).toHaveBeenCalledTimes(1);
|
||||
release.resolve();
|
||||
const chunks = await chunksPromise;
|
||||
const messages = streamText.mock.calls[1][0].messages as Array<{
|
||||
role: string;
|
||||
content: unknown;
|
||||
}>;
|
||||
expect(messages.filter(({ role }) => role === 'user').map(({ content }) => content)).toEqual([
|
||||
[{ type: 'text', text: 'A' }],
|
||||
[{ type: 'text', text: 'C' }],
|
||||
[{ type: 'text', text: 'D' }],
|
||||
]);
|
||||
expect(messages.findIndex(({ role }) => role === 'tool')).toBeLessThan(
|
||||
messages.findIndex(({ content }) => JSON.stringify(content).includes('"C"')),
|
||||
);
|
||||
expect(chunks.filter((chunk) => chunk.type === 'input').map(({ message }) => message)).toEqual(
|
||||
pending,
|
||||
);
|
||||
expect(counter.incrementMessageCount).toHaveBeenCalledTimes(1);
|
||||
expect(counter.incrementToolCallCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ maxIterations: 1, finishReason: 'stop' },
|
||||
{ maxIterations: 2, finishReason: 'stop' },
|
||||
{ maxIterations: 1, finishReason: 'error' },
|
||||
{ maxIterations: 2, finishReason: 'error' },
|
||||
])(
|
||||
'handles $finishReason completion with a $maxIterations-call limit',
|
||||
async ({ maxIterations, finishReason }) => {
|
||||
streamText.mockReturnValue(makeStreamSuccess('response'));
|
||||
const error = new Error('Model stream failed');
|
||||
if (finishReason === 'error') {
|
||||
streamText.mockReturnValueOnce({
|
||||
...makeStreamSuccess('partial response'),
|
||||
stream: makeChunkStream([
|
||||
{ type: 'text-delta', id: 'text-1', text: 'partial response' },
|
||||
{ type: 'error', error },
|
||||
]),
|
||||
finishReason: Promise.resolve('error'),
|
||||
});
|
||||
}
|
||||
const pending = input('C');
|
||||
let consumed = false;
|
||||
const onInputBoundary = vi.fn(
|
||||
async ({ completing, canContinue }: { completing: boolean; canContinue: boolean }) => {
|
||||
if (!completing || !canContinue || consumed) return [];
|
||||
consumed = true;
|
||||
return [pending];
|
||||
},
|
||||
);
|
||||
const { runtime } = createRuntime();
|
||||
const chunks = await collectChunks(
|
||||
(await runtime.stream('A', { maxIterations, onInputBoundary })).stream,
|
||||
);
|
||||
expect(streamText).toHaveBeenCalledTimes(finishReason === 'error' ? 1 : maxIterations);
|
||||
expect(consumed).toBe(finishReason === 'stop' && maxIterations === 2);
|
||||
expect(chunks.filter((chunk) => chunk.type === 'input')).toHaveLength(consumed ? 1 : 0);
|
||||
if (finishReason === 'error') {
|
||||
expect(onInputBoundary).toHaveBeenCalledTimes(1);
|
||||
expect(chunks).toContainEqual({ type: 'error', error });
|
||||
} else {
|
||||
expect(onInputBoundary).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ completing: true, canContinue: false }),
|
||||
);
|
||||
}
|
||||
expect(chunks.at(-1)).toMatchObject({ type: 'finish', finishReason });
|
||||
},
|
||||
);
|
||||
|
||||
it('applies the attachment budget to earlier input and steered input together', async () => {
|
||||
const bytes = new Uint8Array(40 * 1024 * 1024);
|
||||
const runtime = new AgentRuntime({
|
||||
name: 'attachment-test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'Test',
|
||||
fileStore: { load: vi.fn().mockResolvedValue(bytes) },
|
||||
});
|
||||
const earlier = input('A');
|
||||
const steered = input('C');
|
||||
for (const message of [earlier, steered]) {
|
||||
if ('role' in message) {
|
||||
message.content.push({
|
||||
type: 'file',
|
||||
mediaType: 'image/png',
|
||||
fileRef: { id: message.id, sizeBytes: bytes.byteLength },
|
||||
});
|
||||
}
|
||||
}
|
||||
generateText.mockResolvedValue(makeGenerateSuccess());
|
||||
const onInputBoundary = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([steered])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await runtime.generate([earlier], { maxIterations: 2, onInputBoundary });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(generateText).toHaveBeenCalledTimes(2);
|
||||
const messages: aiModule.ModelMessage[] = generateText.mock.calls[1][0].messages ?? [];
|
||||
const files = messages.flatMap((message) => {
|
||||
if (!Array.isArray(message.content)) return [];
|
||||
return message.content.filter((part) => part.type === 'file');
|
||||
});
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].data).toBe(bytes);
|
||||
});
|
||||
|
||||
it('discards earlier structured output when steered input reaches the iteration limit', async () => {
|
||||
const tool: BuiltTool = {
|
||||
name: 'search',
|
||||
description: 'Search',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => 'tool result',
|
||||
};
|
||||
const response = makeGenerateWithToolCall('search-1', 'search', {});
|
||||
streamText
|
||||
.mockReturnValueOnce(makeStreamSuccessWithOutput({ answer: 'A', score: 1 }))
|
||||
.mockReturnValueOnce({
|
||||
...makeStreamSuccess(),
|
||||
stream: makeChunkStream([{ type: 'tool-call', ...response.toolCalls[0] }]),
|
||||
finishReason: Promise.resolve('tool-calls'),
|
||||
response: Promise.resolve(response.response),
|
||||
toolCalls: Promise.resolve(response.toolCalls),
|
||||
});
|
||||
const onInputBoundary = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([input('C')])
|
||||
.mockResolvedValue([]);
|
||||
const { runtime } = createStructuredRuntime({ tools: [tool] });
|
||||
const chunks = await collectChunks(
|
||||
(await runtime.stream('A', { maxIterations: 2, onInputBoundary })).stream,
|
||||
);
|
||||
|
||||
expect(streamText).toHaveBeenCalledTimes(2);
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: 'finish',
|
||||
finishReason: 'max-iterations',
|
||||
});
|
||||
expect(chunks.at(-1)).not.toHaveProperty('structuredOutput');
|
||||
});
|
||||
|
||||
it.each(['abort', 'disconnect'] as const)(
|
||||
'releases an unacknowledged boundary on %s',
|
||||
async (action) => {
|
||||
streamText.mockReturnValue(makeStreamSuccess());
|
||||
const onInputBoundary = vi.fn().mockResolvedValue([]);
|
||||
const controller = new AbortController();
|
||||
const { runtime } = createRuntime();
|
||||
const result = await runtime.stream('A', { abortSignal: controller.signal, onInputBoundary });
|
||||
const reader = result.stream.getReader();
|
||||
expect((await reader.read()).value).toMatchObject({ type: 'input-boundary' });
|
||||
expect(onInputBoundary).not.toHaveBeenCalled();
|
||||
if (action === 'disconnect') await reader.cancel();
|
||||
else {
|
||||
controller.abort();
|
||||
while (!(await reader.read()).done) {
|
||||
/* Drain terminal chunks. */
|
||||
}
|
||||
}
|
||||
expect(streamText).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('AgentRuntime — execution counters', () => {
|
||||
beforeEach(() => {
|
||||
generateText.mockReset();
|
||||
|
|
|
|||
|
|
@ -577,7 +577,12 @@ describe('agent-directed episodic capture', () => {
|
|||
scope: { resourceId: 'user-1', threadId: 'thread-1' },
|
||||
now: new Date('2026-05-12T11:00:00.000Z'),
|
||||
}),
|
||||
).resolves.toEqual({ status: 'ran', entriesWritten: 1, candidatesProcessed: 1 });
|
||||
).resolves.toEqual({
|
||||
status: 'ran',
|
||||
entriesWritten: 1,
|
||||
candidatesProcessed: 1,
|
||||
usageReports: expect.any(Array),
|
||||
});
|
||||
|
||||
// Reflection only runs for corrections; a plain preference must not pay for it.
|
||||
expect(reflect).not.toHaveBeenCalled();
|
||||
|
|
@ -767,4 +772,101 @@ describe('agent-directed episodic capture', () => {
|
|||
),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never fails candidate processing when the embed onUsage throws or rejects', async () => {
|
||||
// Pricing is best-effort: a misbehaving onUsage callback (sync throw or
|
||||
// rejected promise) at the embed forwarding site must not abort
|
||||
// candidate processing or surface an unhandled rejection. Entries are
|
||||
// still saved.
|
||||
const syncThrow = vi.fn(() => {
|
||||
throw new Error('pricing sync boom');
|
||||
});
|
||||
const rejecting = vi.fn(async () => {
|
||||
throw new Error('pricing async boom');
|
||||
});
|
||||
|
||||
for (const onUsage of [syncThrow, rejecting]) {
|
||||
const memory = new InMemoryMemory();
|
||||
await enqueueCandidate(memory);
|
||||
mockedEmbedMany.mockResolvedValue({
|
||||
embeddings: [[1, 0]],
|
||||
usage: { tokens: 6 },
|
||||
} as never);
|
||||
|
||||
const result = await runEpisodicMemoryCandidateProcessor({
|
||||
memory,
|
||||
config: { embedder: fakeEmbedder },
|
||||
scope: { resourceId: 'user-1', threadId: 'thread-1' },
|
||||
onUsage,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: 'ran' });
|
||||
expect(onUsage).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
memory.episodic.searchEntries(
|
||||
{ resourceId: 'user-1', threadId: 'thread-1' },
|
||||
'concise reports',
|
||||
),
|
||||
).resolves.toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('never fails reflection when the reflect onUsage throws or rejects', async () => {
|
||||
// The reflect forwarding site must not abort reflection when onUsage
|
||||
// throws or rejects. The reflection's merge still applies.
|
||||
const syncThrow = vi.fn(() => {
|
||||
throw new Error('pricing sync boom');
|
||||
});
|
||||
const rejecting = vi.fn(async () => {
|
||||
throw new Error('pricing async boom');
|
||||
});
|
||||
|
||||
for (const onUsage of [syncThrow, rejecting]) {
|
||||
const memory = new InMemoryMemory();
|
||||
const legacy = await saveEpisodicEntry(memory, {
|
||||
resourceId: 'user-1',
|
||||
content: 'User planned SQLite for local-first memory storage.',
|
||||
embedding: [0, 1],
|
||||
embeddingModel: 'openai/text-embedding-3-small',
|
||||
});
|
||||
await enqueueCandidate(memory, 'call-1', 'run-1', {
|
||||
content: 'User switched memory storage from SQLite to Postgres.',
|
||||
kind: 'correction',
|
||||
});
|
||||
mockedEmbedMany.mockResolvedValue({
|
||||
embeddings: [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
usage: { tokens: 6 },
|
||||
} as never);
|
||||
|
||||
const result = await runEpisodicMemoryCandidateProcessor({
|
||||
memory,
|
||||
config: {
|
||||
embedder: fakeEmbedder,
|
||||
reflect: async ({ seedEntryIds }) =>
|
||||
await Promise.resolve({
|
||||
reflection: {
|
||||
drop: [],
|
||||
merge: [
|
||||
{
|
||||
supersedes: [legacy.id, seedEntryIds[0]],
|
||||
content: 'User replaced the SQLite plan with Postgres.',
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: { promptTokens: 20, completionTokens: 5, totalTokens: 25 },
|
||||
model: 'openai/gpt-4o-mini',
|
||||
}),
|
||||
},
|
||||
scope: { resourceId: 'user-1', threadId: 'thread-1' },
|
||||
onUsage,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: 'ran' });
|
||||
// Embed + reflect + merge-embed = three forwarding calls per run.
|
||||
expect(onUsage).toHaveBeenCalledTimes(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,20 +96,37 @@ describe('hydrateFileParts', () => {
|
|||
expect(store.load).toHaveBeenCalledWith(block.fileRef, { threadId: 'thread-1' });
|
||||
});
|
||||
|
||||
it('hydrates only the newest parts up to the count cap', async () => {
|
||||
it('keeps only the newest parts within the count cap across hydration calls', async () => {
|
||||
const blocks = Array.from({ length: MAX_HYDRATED_FILE_PARTS + 2 }, (_, index) =>
|
||||
refFileBlock({ fileRef: { id: `att-${index}`, sizeBytes: 1 } }),
|
||||
);
|
||||
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(bytes) };
|
||||
|
||||
await hydrateFileParts([userMessage(blocks.slice(0, MAX_HYDRATED_FILE_PARTS))], store);
|
||||
await hydrateFileParts([userMessage(blocks)], store);
|
||||
|
||||
expect(store.load).toHaveBeenCalledTimes(MAX_HYDRATED_FILE_PARTS);
|
||||
expect(store.load).toHaveBeenCalledTimes(MAX_HYDRATED_FILE_PARTS + 2);
|
||||
expect(blocks[0].data).toBeUndefined();
|
||||
expect(blocks[1].data).toBeUndefined();
|
||||
expect(blocks.at(-1)!.data).toBe(bytes);
|
||||
});
|
||||
|
||||
it.each([bytes, bytes.buffer, 'AQID'])(
|
||||
'charges already-loaded data against the byte budget (%s)',
|
||||
async (data) => {
|
||||
const old = refFileBlock({ data });
|
||||
const newest = refFileBlock({ fileRef: { id: 'att-new' } });
|
||||
const large = new Uint8Array(MAX_HYDRATED_FILE_BYTES - 2);
|
||||
const store: BuiltFileStore = { load: vi.fn().mockResolvedValue(large) };
|
||||
|
||||
await hydrateFileParts([userMessage([old, newest])], store);
|
||||
|
||||
expect(old.data).toBeUndefined();
|
||||
expect(old.fileRef?.id).toBe('att-1');
|
||||
expect(newest.data).toBe(large);
|
||||
},
|
||||
);
|
||||
|
||||
it('spends the byte budget newest-first and skips parts that would exceed it', async () => {
|
||||
const oldSmall = refFileBlock({ fileRef: { id: 'att-old', sizeBytes: 3 } });
|
||||
const hugeDeclared = refFileBlock({
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ function textsOf(messages: AgentDbMessage[]): string[] {
|
|||
}
|
||||
|
||||
describe('MemoryOrchestrator.discardRejectedInput', () => {
|
||||
it('removes input from storage, deltas, and serialized state without changing history', async () => {
|
||||
it('removes selected input from storage and state without changing history or accepted input', async () => {
|
||||
const store = new InMemoryMemory();
|
||||
await store.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
const history: AgentDbMessage = {
|
||||
|
|
@ -57,25 +57,29 @@ describe('MemoryOrchestrator.discardRejectedInput', () => {
|
|||
await store.saveMessages({ threadId: THREAD_ID, resourceId: RESOURCE_ID, messages: [history] });
|
||||
const list = new AgentMessageList();
|
||||
list.addHistory([history]);
|
||||
list.addInput([userMsg('Rejected message'), userMsg('Additional input')]);
|
||||
list.addInput([
|
||||
{ ...userMsg('Accepted message'), id: 'accepted', createdAt: new Date() },
|
||||
{ ...userMsg('Rejected message'), id: 'rejected', createdAt: new Date() },
|
||||
]);
|
||||
const accepted = list.inputDelta()[0];
|
||||
const orchestrator = buildOrchestrator(store);
|
||||
await orchestrator.persistInputMessages(list, PERSIST);
|
||||
|
||||
await orchestrator.discardRejectedInput(list, PERSIST);
|
||||
await orchestrator.discardRejectedInput(list, PERSIST);
|
||||
await orchestrator.discardRejectedInput(list, ['rejected'], PERSIST);
|
||||
await orchestrator.discardRejectedInput(list, ['rejected'], PERSIST);
|
||||
await orchestrator.persistInputMessages(list, PERSIST);
|
||||
await orchestrator.persistTurnDelta(list, PERSIST);
|
||||
await orchestrator.saveToMemory(list, PERSIST);
|
||||
|
||||
expect(list.inputDelta()).toEqual([]);
|
||||
expect(list.turnDelta()).toEqual([]);
|
||||
expect(list.inputDelta()).toEqual([accepted]);
|
||||
expect(list.turnDelta()).toEqual([accepted]);
|
||||
expect(list.serialize()).toMatchObject({
|
||||
messages: [history],
|
||||
inputIds: [],
|
||||
messages: [history, accepted],
|
||||
inputIds: ['accepted'],
|
||||
historyIds: ['history'],
|
||||
});
|
||||
expect(AgentMessageList.deserialize(list.serialize()).turnDelta()).toEqual([]);
|
||||
expect(await store.getMessages(THREAD_ID)).toEqual([history]);
|
||||
expect(AgentMessageList.deserialize(list.serialize()).turnDelta()).toEqual([accepted]);
|
||||
expect(await store.getMessages(THREAD_ID)).toEqual([history, accepted]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import type * as AiImport from 'ai';
|
||||
|
||||
import type { ObservationalMemoryConfig } from '../../types';
|
||||
import type { ExecutionOptions, RunOptions, SideCallUsageReport } from '../../types/sdk/agent';
|
||||
import type { AgentMessage } from '../../types/sdk/message';
|
||||
import type { AgentRuntimeConfig } from '../loop/agent-runtime';
|
||||
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
|
||||
import { InMemoryMemory } from '../memory/memory-store';
|
||||
import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner';
|
||||
import { AgentMessageList } from '../model/message-list';
|
||||
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
||||
import { AgentEventBus } from '../state/event-bus';
|
||||
import { RuntimeTelemetry } from '../telemetry/runtime-telemetry';
|
||||
|
||||
const THREAD_ID = 'thread-1';
|
||||
const RESOURCE_ID = 'user-1';
|
||||
const OBSERVER_MODEL = 'openai/gpt-4o';
|
||||
|
||||
const { mockGetModelCost } = vi.hoisted(() => ({
|
||||
mockGetModelCost: vi.fn<(...args: [string]) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
vi.mock('ai', async () => {
|
||||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
vi.mock('../../sdk/catalog', async (importOriginal) => ({
|
||||
...(await importOriginal<{}>()),
|
||||
getModelCost: mockGetModelCost,
|
||||
}));
|
||||
|
||||
function userMsg(text: string): AgentMessage {
|
||||
return { role: 'user', content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function assistantMsg(text: string): AgentMessage {
|
||||
return { role: 'assistant', content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function runOptions(
|
||||
onSideCallUsage?: (report: SideCallUsageReport) => void,
|
||||
): RunOptions & ExecutionOptions {
|
||||
return {
|
||||
persistence: { threadId: THREAD_ID, resourceId: RESOURCE_ID },
|
||||
onSideCallUsage,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOrchestrator(observationalMemory: ObservationalMemoryConfig): {
|
||||
orchestrator: MemoryOrchestrator;
|
||||
events: ScopedMemoryTaskEvent[];
|
||||
} {
|
||||
const events: ScopedMemoryTaskEvent[] = [];
|
||||
const config = {
|
||||
name: 'side-call-agent',
|
||||
memory: new InMemoryMemory(),
|
||||
observationalMemory,
|
||||
onMemoryTaskEvent: (event: ScopedMemoryTaskEvent) => events.push(event),
|
||||
} as unknown as AgentRuntimeConfig;
|
||||
const tracker = new BackgroundTaskTracker();
|
||||
const orchestrator = new MemoryOrchestrator(
|
||||
config,
|
||||
tracker,
|
||||
new AgentEventBus(),
|
||||
new RuntimeTelemetry(config),
|
||||
async (text) => await Promise.resolve(text.length),
|
||||
);
|
||||
return { orchestrator, events };
|
||||
}
|
||||
|
||||
describe('MemoryOrchestrator side-call cost forwarding', () => {
|
||||
beforeEach(() => {
|
||||
mockGetModelCost.mockReset();
|
||||
});
|
||||
|
||||
it('forwards a priced observer usage report to onSideCallUsage', async () => {
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
const { orchestrator } = buildOrchestrator({
|
||||
observerThresholdTokens: 10,
|
||||
observationLogTailLimit: 20,
|
||||
observe: async () =>
|
||||
await Promise.resolve({
|
||||
text: '* CRITICAL (14:30) User set up mid-run observation.',
|
||||
usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 },
|
||||
model: OBSERVER_MODEL,
|
||||
}),
|
||||
});
|
||||
|
||||
const reports: SideCallUsageReport[] = [];
|
||||
const list = new AgentMessageList();
|
||||
list.addInput([userMsg('a user message crossing the threshold')]);
|
||||
list.addResponse([assistantMsg('an assistant reply with more work')]);
|
||||
|
||||
await orchestrator.maybeObserveMidRun(
|
||||
list,
|
||||
runOptions((report) => reports.push(report)),
|
||||
);
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
const report = reports[0];
|
||||
expect(report.task).toBe('observer');
|
||||
expect(report.model).toBe(OBSERVER_MODEL);
|
||||
expect(report.usage.promptTokens).toBe(80);
|
||||
expect(report.usage.completionTokens).toBe(20);
|
||||
// 80 input at $5/M = $0.0004; 20 output at $15/M = $0.0003.
|
||||
expect(report.cost).toBeCloseTo(0.0007, 7);
|
||||
expect(report.reportId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('does not forward a report when the observer returns no usage', async () => {
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
const { orchestrator } = buildOrchestrator({
|
||||
observerThresholdTokens: 10,
|
||||
observationLogTailLimit: 20,
|
||||
observe: async () => await Promise.resolve('* CRITICAL (14:30) No usage reported.'),
|
||||
});
|
||||
|
||||
const reports: SideCallUsageReport[] = [];
|
||||
const list = new AgentMessageList();
|
||||
list.addInput([userMsg('a user message crossing the threshold')]);
|
||||
list.addResponse([assistantMsg('an assistant reply with more work')]);
|
||||
|
||||
await orchestrator.maybeObserveMidRun(
|
||||
list,
|
||||
runOptions((report) => reports.push(report)),
|
||||
);
|
||||
expect(reports).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not forward a report when the model has no catalog pricing', async () => {
|
||||
mockGetModelCost.mockResolvedValue(undefined);
|
||||
const { orchestrator } = buildOrchestrator({
|
||||
observerThresholdTokens: 10,
|
||||
observationLogTailLimit: 20,
|
||||
observe: async () =>
|
||||
await Promise.resolve({
|
||||
text: '* CRITICAL (14:30) Unpriced observer call.',
|
||||
usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 },
|
||||
model: 'unknown/model',
|
||||
}),
|
||||
});
|
||||
|
||||
const reports: SideCallUsageReport[] = [];
|
||||
const list = new AgentMessageList();
|
||||
list.addInput([userMsg('a user message crossing the threshold')]);
|
||||
list.addResponse([assistantMsg('an assistant reply with more work')]);
|
||||
|
||||
await orchestrator.maybeObserveMidRun(
|
||||
list,
|
||||
runOptions((report) => reports.push(report)),
|
||||
);
|
||||
expect(reports).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not break the run when onSideCallUsage throws', async () => {
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
const { orchestrator } = buildOrchestrator({
|
||||
observerThresholdTokens: 10,
|
||||
observationLogTailLimit: 20,
|
||||
observe: async () =>
|
||||
await Promise.resolve({
|
||||
text: '* CRITICAL (14:30) Observer call.',
|
||||
usage: { promptTokens: 80, completionTokens: 20, totalTokens: 100 },
|
||||
model: OBSERVER_MODEL,
|
||||
}),
|
||||
});
|
||||
|
||||
const list = new AgentMessageList();
|
||||
list.addInput([userMsg('a user message crossing the threshold')]);
|
||||
list.addResponse([assistantMsg('an assistant reply with more work')]);
|
||||
|
||||
await expect(
|
||||
orchestrator.maybeObserveMidRun(
|
||||
list,
|
||||
runOptions(() => {
|
||||
throw new Error('host callback exploded');
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -109,7 +109,10 @@ describe('observation-log observer defaults', () => {
|
|||
executionCounter: counter,
|
||||
});
|
||||
|
||||
expect(result).toContain('CRITICAL');
|
||||
// The default observe fn returns { text, usage, model }; bare-string returns
|
||||
// stay supported for custom observe fns.
|
||||
const observeText = typeof result === 'string' ? result : result.text;
|
||||
expect(observeText).toContain('CRITICAL');
|
||||
expect(counter.incrementTokenCount).toHaveBeenCalledWith(17);
|
||||
expect(counter.incrementMessageCount).not.toHaveBeenCalled();
|
||||
expect(counter.incrementToolCallCount).not.toHaveBeenCalled();
|
||||
|
|
@ -477,6 +480,39 @@ describe('runObservationLogObserver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('never fails observation when onUsage throws synchronously or rejects', async () => {
|
||||
// Pricing is best-effort: a misbehaving onUsage callback (sync throw or
|
||||
// rejected promise) must not abort the observer or surface an unhandled
|
||||
// rejection. Observation still completes and writes entries.
|
||||
const syncThrow = vi.fn(() => {
|
||||
throw new Error('pricing sync boom');
|
||||
});
|
||||
const rejecting = vi.fn(async () => {
|
||||
throw new Error('pricing async boom');
|
||||
});
|
||||
|
||||
for (const onUsage of [syncThrow, rejecting]) {
|
||||
const store = new InMemoryMemory();
|
||||
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
|
||||
await store.saveMessages({
|
||||
threadId: 'thread-1',
|
||||
resourceId: 'user-1',
|
||||
messages: [message('m1', 'user', 'I need this remembered.', new Date(2026, 4, 12, 14, 30))],
|
||||
});
|
||||
const result = await runObservationLogObserver({
|
||||
memory: store,
|
||||
observationScopeId: 'thread-1',
|
||||
observationLogTailLimit: 20,
|
||||
tokenCounter: async () => await Promise.resolve(5),
|
||||
now: new Date(2026, 4, 12, 14, 31),
|
||||
observe: async () => await Promise.resolve('* CRITICAL (14:31) Durable fact.'),
|
||||
onUsage,
|
||||
});
|
||||
expect(result).toMatchObject({ status: 'ran', observationsWritten: 1 });
|
||||
expect(onUsage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' \nnot a bullet line\n',
|
||||
|
|
|
|||
|
|
@ -229,12 +229,18 @@ describe('observation-log reflector defaults', () => {
|
|||
});
|
||||
await vi.waitFor(() => expect(mockGenerateText).toHaveBeenCalledTimes(2));
|
||||
secondResponse.resolve({ text: '{"drop":["1"],"merge":[]}' });
|
||||
expect(parseObservationLogReflectionJson(await secondCall)).toEqual({
|
||||
// The default reflect fn returns { text, usage, model }; extract the text
|
||||
// before parsing the reflection JSON.
|
||||
const secondResult = await secondCall;
|
||||
const secondText = typeof secondResult === 'string' ? secondResult : secondResult.text;
|
||||
expect(parseObservationLogReflectionJson(secondText)).toEqual({
|
||||
drop: [second.id],
|
||||
merge: [],
|
||||
});
|
||||
firstResponse.resolve({ text: '{"drop":["1"],"merge":[]}' });
|
||||
expect(parseObservationLogReflectionJson(await firstCall)).toEqual({
|
||||
const firstResult = await firstCall;
|
||||
const firstText = typeof firstResult === 'string' ? firstResult : firstResult.text;
|
||||
expect(parseObservationLogReflectionJson(firstText)).toEqual({
|
||||
drop: [first.id],
|
||||
merge: [],
|
||||
});
|
||||
|
|
@ -279,7 +285,10 @@ describe('observation-log reflector defaults', () => {
|
|||
executionCounter: counter,
|
||||
});
|
||||
|
||||
expect(result).toBe('{"drop":[],"merge":[]}');
|
||||
// The default reflect fn returns { text, usage, model }; bare-string returns
|
||||
// stay supported for custom reflect fns.
|
||||
const reflectText = typeof result === 'string' ? result : result.text;
|
||||
expect(reflectText).toBe('{"drop":[],"merge":[]}');
|
||||
expect(counter.incrementTokenCount).toHaveBeenCalledWith(19);
|
||||
expect(counter.incrementMessageCount).not.toHaveBeenCalled();
|
||||
expect(counter.incrementToolCallCount).not.toHaveBeenCalled();
|
||||
|
|
@ -608,6 +617,39 @@ describe('runObservationLogReflector', () => {
|
|||
).resolves.toMatchObject([{ tokenCount: 6 }]);
|
||||
});
|
||||
|
||||
it('never fails reflection when onUsage throws synchronously or rejects', async () => {
|
||||
// Pricing is best-effort: a misbehaving onUsage callback must not abort
|
||||
// the reflector or surface an unhandled rejection. Reflection still
|
||||
// applies its drop instruction.
|
||||
const syncThrow = vi.fn(() => {
|
||||
throw new Error('pricing sync boom');
|
||||
});
|
||||
const rejecting = vi.fn(async () => {
|
||||
throw new Error('pricing async boom');
|
||||
});
|
||||
|
||||
for (const onUsage of [syncThrow, rejecting]) {
|
||||
const store = new InMemoryMemory();
|
||||
const [stale] = await store.appendObservationLogEntries([
|
||||
{
|
||||
observationScopeId: 'thread-1',
|
||||
marker: 'info',
|
||||
text: 'Tiny aside',
|
||||
tokenCount: 12,
|
||||
},
|
||||
]);
|
||||
const result = await runObservationLogReflector({
|
||||
memory: store,
|
||||
observationScopeId: 'thread-1',
|
||||
reflectorThresholdTokens: 10,
|
||||
reflect: async () => await Promise.resolve(JSON.stringify({ drop: [stale.id], merge: [] })),
|
||||
onUsage,
|
||||
});
|
||||
expect(result).toMatchObject({ status: 'ran' });
|
||||
expect(onUsage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('warns but still applies reflection output that remains over budget', async () => {
|
||||
const store = new InMemoryMemory();
|
||||
const [critical, stale] = await store.appendObservationLogEntries([
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import type * as AiImport from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
import type { SideCallUsageReport } from '../../types/sdk/agent';
|
||||
import type { BuiltMemory } from '../../types';
|
||||
import { generateThreadTitle } from '../memory/title-generation';
|
||||
import { InMemoryMemory } from '../memory/memory-store';
|
||||
|
||||
type GenerateTextResult = {
|
||||
text: string;
|
||||
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const { mockGenerateText, mockGetModelCost } = vi.hoisted(() => ({
|
||||
mockGenerateText: vi.fn<(...args: unknown[]) => Promise<GenerateTextResult>>(),
|
||||
mockGetModelCost: vi.fn<(...args: [string]) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
vi.mock('ai', async () => {
|
||||
const actual = await vi.importActual<typeof AiImport>('ai');
|
||||
return {
|
||||
...actual,
|
||||
generateText: async (...args: unknown[]) => await mockGenerateText(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../model/model-factory', () => ({
|
||||
createModel: () => ({}) as LanguageModel,
|
||||
}));
|
||||
|
||||
vi.mock('../../sdk/catalog', async (importOriginal) => ({
|
||||
...(await importOriginal<{}>()),
|
||||
getModelCost: mockGetModelCost,
|
||||
}));
|
||||
|
||||
const THREAD_ID = 'thread-1';
|
||||
const RESOURCE_ID = 'user-1';
|
||||
const MODEL_ID = 'openai/gpt-4o';
|
||||
|
||||
async function runTitle(
|
||||
memory: BuiltMemory,
|
||||
onSideCallUsage?: (report: SideCallUsageReport) => void,
|
||||
): Promise<SideCallUsageReport[]> {
|
||||
const reports: SideCallUsageReport[] = [];
|
||||
await generateThreadTitle({
|
||||
memory,
|
||||
threadId: THREAD_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
titleConfig: { model: MODEL_ID },
|
||||
agentModel: MODEL_ID,
|
||||
turnDelta: [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Build a daily Berlin rain alert workflow' }],
|
||||
createdAt: new Date(),
|
||||
} as never,
|
||||
],
|
||||
onSideCallUsage: onSideCallUsage ?? ((report) => reports.push(report)),
|
||||
});
|
||||
return reports;
|
||||
}
|
||||
|
||||
describe('generateThreadTitle side-call cost', () => {
|
||||
beforeEach(() => {
|
||||
mockGenerateText.mockReset();
|
||||
mockGetModelCost.mockReset();
|
||||
});
|
||||
|
||||
it('forwards a priced title usage report to onSideCallUsage', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '{"title":"Berlin rain alert","emoji":"rain"}',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
const report = reports[0];
|
||||
expect(report.task).toBe('title');
|
||||
expect(report.model).toBe(MODEL_ID);
|
||||
expect(report.usage.promptTokens).toBe(100);
|
||||
expect(report.usage.completionTokens).toBe(50);
|
||||
// 100 input tokens at $5/M = $0.0005; 50 output at $15/M = $0.00075.
|
||||
expect(report.cost).toBeCloseTo(0.00125, 7);
|
||||
expect(report.reportId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('forwards a priced title usage report even when the model returns no usable title', async () => {
|
||||
// The model was called and billed, but produced empty text.
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].task).toBe('title');
|
||||
// The thread is not retitled when no title could be extracted.
|
||||
const thread = await memory.getThread(THREAD_ID);
|
||||
expect(thread?.title).toBeFalsy();
|
||||
});
|
||||
|
||||
it('forwards a priced title usage report even when saveThread throws', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '{"title":"Berlin rain alert","emoji":"rain"}',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
// Force persistence to fail after the model call has already been billed.
|
||||
vi.spyOn(memory, 'saveThread').mockRejectedValue(new Error('storage down'));
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
|
||||
// The report is captured before persistence, so the billed turn is
|
||||
// still priced even though saving the title failed.
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0].task).toBe('title');
|
||||
});
|
||||
|
||||
it('does not forward a report when the model has no catalog pricing', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '{"title":"Berlin rain alert","emoji":"rain"}',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue(undefined);
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
expect(reports).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not forward a report when the LLM reports no usage', async () => {
|
||||
mockGenerateText.mockResolvedValue({ text: '{"title":"Berlin rain alert","emoji":"rain"}' });
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
expect(reports).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('mints a unique reportId for each title call', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '{"title":"Berlin rain alert","emoji":"rain"}',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
|
||||
const first = await runTitle(memory);
|
||||
// A second thread so the title is not skipped as already set.
|
||||
const memory2 = new InMemoryMemory();
|
||||
await memory2.saveThread({ id: 'thread-2', resourceId: RESOURCE_ID });
|
||||
const second = await runTitle(memory2);
|
||||
|
||||
expect(first[0].reportId).not.toBe(second[0].reportId);
|
||||
});
|
||||
|
||||
it('skips the cost report when the thread already has a title', async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: '{"title":"Berlin rain alert","emoji":"rain"}',
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
});
|
||||
mockGetModelCost.mockResolvedValue({ input: 5, output: 15 });
|
||||
|
||||
const memory = new InMemoryMemory();
|
||||
await memory.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID, title: 'Existing title' });
|
||||
|
||||
const reports = await runTitle(memory);
|
||||
expect(reports).toHaveLength(0);
|
||||
expect(mockGenerateText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -53,6 +53,7 @@ import { getModelIdString } from '../../utils/model';
|
|||
import { removeToolResultRun } from '../../workspace';
|
||||
import { createFilteredLogger } from '../logger';
|
||||
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
|
||||
import { sanitizeOffloadedToolResultsForMemory } from '../memory/tool-result-memory';
|
||||
import { generateThreadTitle } from '../memory/title-generation';
|
||||
import { AgentMessageList, type SerializedMessageList } from '../model/message-list';
|
||||
import { createModelTokenCounter } from '../model/model-token-counter';
|
||||
|
|
@ -107,7 +108,7 @@ interface PreparedLoopContext extends LoopContext {
|
|||
staticContext: StaticLoopContext;
|
||||
instructionProviderOptions: ProviderOptions | undefined;
|
||||
runTelemetry: BuiltTelemetry | undefined;
|
||||
canDiscardRejectedInput: boolean;
|
||||
acceptedInputIds: Set<string>;
|
||||
}
|
||||
|
||||
interface LoopState {
|
||||
|
|
@ -690,6 +691,8 @@ export class AgentRuntime {
|
|||
modelFetch: this.config.modelFetch,
|
||||
turnDelta: list.turnDelta(),
|
||||
executionCounter: options.executionCounter,
|
||||
onSideCallUsage: options.onSideCallUsage,
|
||||
promptCaching: this.config.promptCaching,
|
||||
});
|
||||
this.backgroundTasks.track(titlePromise);
|
||||
if (this.config.titleGeneration.sync) {
|
||||
|
|
@ -720,11 +723,17 @@ export class AgentRuntime {
|
|||
}
|
||||
|
||||
for (; state.iterationCount < state.maxIterations; state.iterationCount++) {
|
||||
const hasInput = await this.consumeAdditionalInput(prepared, sink, state);
|
||||
if (state.reachedStopCondition && !hasInput) break;
|
||||
state.reachedStopCondition = false;
|
||||
const settlement = await this.runLoopIteration(prepared, sink, state);
|
||||
if (settlement.suspended) return settlement.result;
|
||||
if (state.reachedStopCondition) break;
|
||||
if (state.reachedStopCondition && !prepared.options?.onInputBoundary) break;
|
||||
}
|
||||
|
||||
if (state.iterationCount >= state.maxIterations) {
|
||||
await this.consumeAdditionalInput(prepared, sink, state);
|
||||
}
|
||||
if (!state.reachedStopCondition && state.iterationCount >= state.maxIterations) {
|
||||
state.lastFinishReason = 'max-iterations';
|
||||
}
|
||||
|
|
@ -737,6 +746,35 @@ export class AgentRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
private async consumeAdditionalInput<T>(
|
||||
ctx: PreparedLoopContext,
|
||||
sink: RunOutputSink<T>,
|
||||
state: LoopState,
|
||||
): Promise<boolean> {
|
||||
const receive = ctx.options?.onInputBoundary;
|
||||
if (!receive || state.lastFinishReason === 'error') return false;
|
||||
this.assertNotAborted(ctx.abortScope);
|
||||
await sink.inputBoundary?.(ctx.abortScope.signal);
|
||||
this.assertNotAborted(ctx.abortScope);
|
||||
const messages = await receive({
|
||||
messages: sanitizeOffloadedToolResultsForMemory(ctx.list.turnDelta()),
|
||||
lastCreatedAt: ctx.list.messages().at(-1)?.createdAt.getTime() ?? 0,
|
||||
completing: state.reachedStopCondition,
|
||||
canContinue: state.iterationCount < state.maxIterations,
|
||||
});
|
||||
// Only committed input enters the live list. Its IDs and timestamps stay stable.
|
||||
ctx.list.addInput(messages);
|
||||
for (const message of messages) await sink.emitInput?.(message);
|
||||
this.assertNotAborted(ctx.abortScope);
|
||||
if (messages.length > 0) {
|
||||
state.structuredOutput = undefined;
|
||||
await hydrateFileParts(ctx.list.messages(), this.config.fileStore, {
|
||||
threadId: ctx.options?.persistence?.threadId,
|
||||
});
|
||||
}
|
||||
return messages.length > 0;
|
||||
}
|
||||
|
||||
private async prepareLoop(ctx: LoopContext): Promise<{
|
||||
prepared: PreparedLoopContext;
|
||||
state: LoopState;
|
||||
|
|
@ -767,28 +805,32 @@ export class AgentRuntime {
|
|||
runTelemetry,
|
||||
staticContext,
|
||||
instructionProviderOptions,
|
||||
canDiscardRejectedInput: this.canDiscardRejectedInput(ctx),
|
||||
acceptedInputIds: new Set(
|
||||
ctx.isFreshRun ? [] : list.inputDelta().map((message) => message.id),
|
||||
),
|
||||
},
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
private canDiscardRejectedInput(ctx: LoopContext): boolean {
|
||||
const inputMessages = new Set(ctx.list.inputDelta());
|
||||
const inputIds = new Set([...inputMessages].map((message) => message.id));
|
||||
return (
|
||||
ctx.isFreshRun === true &&
|
||||
[...inputMessages].some(
|
||||
(message) =>
|
||||
'role' in message &&
|
||||
message.role === 'user' &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some((part) => part.type === 'file' && part.data !== undefined),
|
||||
) &&
|
||||
!ctx.list
|
||||
.messages()
|
||||
.some((message) => !inputMessages.has(message) && inputIds.has(message.id))
|
||||
private getRejectableInputIds(ctx: PreparedLoopContext): string[] {
|
||||
const inputMessages = new Set(
|
||||
ctx.list.inputDelta().filter((message) => !ctx.acceptedInputIds.has(message.id)),
|
||||
);
|
||||
const inputIds = new Set([...inputMessages].map((message) => message.id));
|
||||
const hasFiles = [...inputMessages].some(
|
||||
(message) =>
|
||||
'role' in message &&
|
||||
message.role === 'user' &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some((part) => part.type === 'file' && part.data !== undefined),
|
||||
);
|
||||
if (!hasFiles) return [];
|
||||
if (
|
||||
ctx.list.messages().some((message) => !inputMessages.has(message) && inputIds.has(message.id))
|
||||
)
|
||||
return [];
|
||||
return [...inputIds];
|
||||
}
|
||||
|
||||
private buildToolBatchContext(
|
||||
|
|
@ -844,9 +886,10 @@ export class AgentRuntime {
|
|||
): Promise<ToolBatchSettlement<T>> {
|
||||
this.assertNotAborted(ctx.abortScope);
|
||||
this.eventBus.emit({ type: AgentEvent.TurnStart });
|
||||
const { toolMap, modelCallContext } = await this.prepareModelCall(ctx, state.iterationCount);
|
||||
const { toolMap, modelCallContext } = await this.prepareModelCall(ctx);
|
||||
const turn = await this.callModelWithRetries(ctx, sink, state, modelCallContext);
|
||||
this.assertNotAborted(ctx.abortScope);
|
||||
for (const message of ctx.list.inputDelta()) ctx.acceptedInputIds.add(message.id);
|
||||
|
||||
state.lastFinishReason = turn.finishReason;
|
||||
if (!isReasoningOnlyStop(turn)) ctx.list.addResponse(turn.newMessages);
|
||||
|
|
@ -878,7 +921,7 @@ export class AgentRuntime {
|
|||
return settlement;
|
||||
}
|
||||
|
||||
private async prepareModelCall(ctx: PreparedLoopContext, iterationCount: number) {
|
||||
private async prepareModelCall(ctx: PreparedLoopContext) {
|
||||
const { list, options, abortScope, staticContext } = ctx;
|
||||
for (const toolName of this.activeSkills?.toolDependencies() ?? []) {
|
||||
this.deferredToolManager?.load(toolName);
|
||||
|
|
@ -909,19 +952,19 @@ export class AgentRuntime {
|
|||
outputSpec: staticContext.outputSpec,
|
||||
maxOutputTokens: staticContext.maxOutputTokens,
|
||||
aiSdkOptions: this.buildAiSdkOptions(tools.toolMap, options),
|
||||
onInputRejected: this.createInputRejectionHandler(ctx, iterationCount),
|
||||
onInputRejected: this.createInputRejectionHandler(ctx),
|
||||
};
|
||||
return { toolMap: tools.toolMap, modelCallContext };
|
||||
}
|
||||
|
||||
private createInputRejectionHandler(
|
||||
ctx: PreparedLoopContext,
|
||||
iterationCount: number,
|
||||
): ModelCallContext['onInputRejected'] {
|
||||
if (!ctx.canDiscardRejectedInput || iterationCount !== 0) return undefined;
|
||||
const messageIds = this.getRejectableInputIds(ctx);
|
||||
if (messageIds.length === 0) return undefined;
|
||||
return async () => {
|
||||
if (ctx.abortScope.isAborted) return;
|
||||
await this.memory.discardRejectedInput(ctx.list, ctx.options);
|
||||
await this.memory.discardRejectedInput(ctx.list, messageIds, ctx.options);
|
||||
this.updateState({ messageList: ctx.list.serialize() });
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AgentDbMessage, ContentFile } from '../../types/sdk/message';
|
|||
|
||||
/** Only the newest file parts are hydrated; older ones stay reference-only. */
|
||||
export const MAX_HYDRATED_FILE_PARTS = 20;
|
||||
/** Cumulative cap on hydrated bytes per turn. */
|
||||
/** Cumulative cap on hydrated bytes in the live message list. */
|
||||
export const MAX_HYDRATED_FILE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
|
|
@ -32,25 +32,31 @@ export async function hydrateFileParts(
|
|||
for (const message of messages) {
|
||||
if (!('content' in message) || !Array.isArray(message.content)) continue;
|
||||
for (const block of message.content) {
|
||||
if (block.type !== 'file' || !block.fileRef || block.data !== undefined) continue;
|
||||
if (block.type !== 'file' || !block.fileRef) continue;
|
||||
if (fileStore.isMediaTypeSupported && !fileStore.isMediaTypeSupported(block.mediaType)) {
|
||||
delete block.data;
|
||||
continue;
|
||||
}
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of blocks.slice(0, -MAX_HYDRATED_FILE_PARTS)) delete block.data;
|
||||
let budget = MAX_HYDRATED_FILE_BYTES;
|
||||
const newestFirst = blocks.slice(-MAX_HYDRATED_FILE_PARTS).reverse();
|
||||
for (const block of newestFirst) {
|
||||
if (budget <= 0) break;
|
||||
const existingData = block.data;
|
||||
delete block.data;
|
||||
if (budget <= 0) continue;
|
||||
const declaredSize = block.fileRef!.sizeBytes;
|
||||
if (declaredSize !== undefined && declaredSize > budget) continue;
|
||||
if (existingData === undefined && declaredSize !== undefined && declaredSize > budget) continue;
|
||||
try {
|
||||
const data = await fileStore.load(block.fileRef!, scope);
|
||||
if (!data || data.byteLength > budget) continue;
|
||||
const data = existingData ?? (await fileStore.load(block.fileRef!, scope));
|
||||
if (!data) continue;
|
||||
const size = typeof data === 'string' ? Buffer.byteLength(data, 'base64') : data.byteLength;
|
||||
if (size > budget) continue;
|
||||
block.data = data;
|
||||
budget -= data.byteLength;
|
||||
budget -= size;
|
||||
} catch {
|
||||
// Leave the block reference-only; the model sees its text metadata.
|
||||
}
|
||||
|
|
|
|||
23
packages/@n8n/agents/src/runtime/loop/side-call-cost.ts
Normal file
23
packages/@n8n/agents/src/runtime/loop/side-call-cost.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { computeCost, getModelCost } from '../../sdk/catalog';
|
||||
import type { PromptCachingConfig, TokenUsage } from '../../types/sdk/agent';
|
||||
import { getEffectiveAnthropicCacheTtl } from '../model/prompt-cache';
|
||||
|
||||
/**
|
||||
* Compute the estimated USD cost of a side-call model turn (title
|
||||
* generation, observation-log observer/reflector, episodic-memory model
|
||||
* call) from models.dev pricing. Returns `undefined` when the catalog is
|
||||
* unavailable or the model has no pricing, so callers can skip the increment
|
||||
* instead of billing zero. `promptCaching` only affects Anthropic cache-write
|
||||
* pricing; it is a no-op for non-Anthropic side-call models.
|
||||
*/
|
||||
export async function computeSideCallCost(
|
||||
modelId: string,
|
||||
usage: TokenUsage,
|
||||
promptCaching?: PromptCachingConfig,
|
||||
): Promise<number | undefined> {
|
||||
const modelCost = await getModelCost(modelId);
|
||||
if (!modelCost) return undefined;
|
||||
return computeCost(usage, modelCost, {
|
||||
anthropicCacheTtl: getEffectiveAnthropicCacheTtl(promptCaching, modelId),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import type { StreamTextTransform, TextStreamPart, ToolSet } from 'ai';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { raceWithAbort } from '../../sdk/abort';
|
||||
|
||||
import { finalizeRun } from './run-output-sink';
|
||||
import type {
|
||||
|
|
@ -11,7 +13,7 @@ import type {
|
|||
} from '../../types/runtime/agent-loop';
|
||||
import { classifyModelTurnError, mergeUsage } from './runtime-helpers';
|
||||
import type { ExecutionOptions, TokenUsage } from '../../types/sdk/agent';
|
||||
import type { AgentMessage } from '../../types/sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage } from '../../types/sdk/message';
|
||||
import { isAttachmentValidationError } from '../model/attachment-validation-error';
|
||||
import { loadAi } from '../model/lazy-ai';
|
||||
import { fromAiFinishReason, fromAiMessages } from '../model/messages';
|
||||
|
|
@ -53,6 +55,25 @@ const STALL_RETRY_SAFE_CHUNK_TYPES = new Set<string>([
|
|||
* chunks. Owns the smooth-stream transform option.
|
||||
*/
|
||||
export class StreamSink implements RunOutputSink<void> {
|
||||
async inputBoundary(signal: AbortSignal): Promise<void> {
|
||||
const acknowledged = createDeferredPromise();
|
||||
await raceWithAbort(
|
||||
async () => {
|
||||
await this.guard.write({
|
||||
type: 'input-boundary',
|
||||
acknowledge: () => acknowledged.resolve(),
|
||||
});
|
||||
if (this.guard.isClosed) throw new Error('Agent stream closed before input consumption');
|
||||
await acknowledged.promise;
|
||||
},
|
||||
AbortSignal.any([signal, this.guard.closedSignal]),
|
||||
);
|
||||
}
|
||||
|
||||
async emitInput(message: AgentDbMessage): Promise<void> {
|
||||
await this.guard.write({ type: 'input', message });
|
||||
}
|
||||
|
||||
private lastUsage: TokenUsage | undefined;
|
||||
// Reads the in-flight turn's usage from the provider's raw stream events so an
|
||||
// aborted run can still be billed (the SDK reports no usage on abort). The
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from './episodic-memory';
|
||||
import { DEFAULT_EPISODIC_MEMORY_CAPTURE_TOOL_INSTRUCTION } from './episodic-memory-defaults';
|
||||
import { normalizeFlatReflectionActions } from './memory-lifecycle';
|
||||
import { reportSideCallUsage } from './forward-usage';
|
||||
import { saveMessagesToThread } from './memory-store';
|
||||
import { redactText } from '../../sdk/guardrails';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
|
|
@ -26,7 +27,11 @@ import type {
|
|||
EpisodicMemoryScope,
|
||||
RetrievedEpisodicMemoryEntry,
|
||||
} from '../../types';
|
||||
import type { AgentExecutionCounter, AgentPersistenceOptions } from '../../types/sdk/agent';
|
||||
import type {
|
||||
AgentExecutionCounter,
|
||||
AgentPersistenceOptions,
|
||||
TokenUsage,
|
||||
} from '../../types/sdk/agent';
|
||||
import type { AgentDbMessage, Message } from '../../types/sdk/message';
|
||||
import { incrementTokenCountFromUsage } from '../loop/execution-counter';
|
||||
import type { AgentMessageList } from '../model/message-list';
|
||||
|
|
@ -153,11 +158,31 @@ export interface RunEpisodicMemoryCandidateProcessorOpts {
|
|||
executionCounter?: AgentExecutionCounter;
|
||||
telemetry?: BuiltTelemetry;
|
||||
agentName?: string;
|
||||
/**
|
||||
* Receives each episodic model call's usage the moment it resolves — once
|
||||
* per embed call and once per reflect call — before any persistence.
|
||||
* Forwarding it here (rather than only on the success return) keeps a
|
||||
* billed embed/reflect turn priced even when later persistence throws.
|
||||
* Fire-and-forget from the caller's perspective.
|
||||
*/
|
||||
onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type EpisodicMemoryUsageReport = {
|
||||
task: 'episodic';
|
||||
model: string;
|
||||
usage: TokenUsage;
|
||||
};
|
||||
|
||||
export type RunEpisodicMemoryCandidateProcessorResult =
|
||||
| { status: 'skipped' }
|
||||
| { status: 'ran'; entriesWritten: number; candidatesProcessed: number };
|
||||
| {
|
||||
status: 'ran';
|
||||
entriesWritten: number;
|
||||
candidatesProcessed: number;
|
||||
/** One usage report per embed/reflect model call, so each is priced separately. */
|
||||
usageReports: EpisodicMemoryUsageReport[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Flagged candidates become entries as written: the agent already phrased the
|
||||
|
|
@ -177,9 +202,12 @@ export async function runEpisodicMemoryCandidateProcessor(
|
|||
|
||||
const now = opts.now ?? new Date();
|
||||
const candidateIds = candidates.map((candidate) => candidate.id);
|
||||
const usageReports: EpisodicMemoryUsageReport[] = [];
|
||||
let savedEntries: EpisodicMemoryEntry[];
|
||||
try {
|
||||
savedEntries = await saveCandidateEntries(opts, config, candidates, now);
|
||||
const saved = await saveCandidateEntries(opts, config, candidates, now);
|
||||
savedEntries = saved.entries;
|
||||
if (saved.usageReport) usageReports.push(saved.usageReport);
|
||||
await opts.memory.episodic.completeCaptureCandidates(candidateIds);
|
||||
} catch (error) {
|
||||
await opts.memory.episodic.recordCaptureCandidateFailure(
|
||||
|
|
@ -194,12 +222,20 @@ export async function runEpisodicMemoryCandidateProcessor(
|
|||
config.reflect &&
|
||||
candidates.some((candidate) => candidate.kind === 'correction')
|
||||
) {
|
||||
await runEpisodicMemoryReflection(opts, config, savedEntries, candidates, now);
|
||||
const reflectReports = await runEpisodicMemoryReflection(
|
||||
opts,
|
||||
config,
|
||||
savedEntries,
|
||||
candidates,
|
||||
now,
|
||||
);
|
||||
usageReports.push(...reflectReports);
|
||||
}
|
||||
return {
|
||||
status: 'ran',
|
||||
entriesWritten: savedEntries.length,
|
||||
candidatesProcessed: candidates.length,
|
||||
usageReports,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -284,13 +320,25 @@ async function embedTexts(
|
|||
config: NormalizedEpisodicMemoryConfig,
|
||||
values: string[],
|
||||
executionCounter: AgentExecutionCounter | undefined,
|
||||
): Promise<number[][]> {
|
||||
): Promise<{ embeddings: number[][]; report?: EpisodicMemoryUsageReport }> {
|
||||
const { embedMany } = await import('ai');
|
||||
const { embeddings, usage } = await withEmbeddingErrorContext(
|
||||
async () => await embedMany({ model: config.embedder, values }),
|
||||
);
|
||||
incrementTokenCountFromUsage(executionCounter, usage);
|
||||
return embeddings;
|
||||
// Embedding model usage only reports a single `tokens` count (no
|
||||
// input/output split), so build the TokenUsage directly instead of
|
||||
// routing through `toTokenUsage`, which expects a chat-model usage shape.
|
||||
const tokenUsage: TokenUsage | undefined = usage
|
||||
? { promptTokens: usage.tokens, completionTokens: 0, totalTokens: usage.tokens }
|
||||
: undefined;
|
||||
return {
|
||||
embeddings,
|
||||
report:
|
||||
tokenUsage !== undefined
|
||||
? { task: 'episodic', model: config.embeddingModel, usage: tokenUsage }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCandidateEntries(
|
||||
|
|
@ -298,8 +346,9 @@ async function saveCandidateEntries(
|
|||
config: NormalizedEpisodicMemoryConfig,
|
||||
candidates: EpisodicMemoryCaptureCandidate[],
|
||||
now: Date,
|
||||
): Promise<EpisodicMemoryEntry[]> {
|
||||
): Promise<{ entries: EpisodicMemoryEntry[]; usageReport?: EpisodicMemoryUsageReport }> {
|
||||
const savedEntries: EpisodicMemoryEntry[] = [];
|
||||
let usageReport: EpisodicMemoryUsageReport | undefined;
|
||||
await withMemorySpan(
|
||||
'save_memory',
|
||||
opts.agentName ?? 'agent',
|
||||
|
|
@ -310,11 +359,19 @@ async function saveCandidateEntries(
|
|||
...inferMemoryStoreAttributes(opts.memory),
|
||||
}),
|
||||
async () => {
|
||||
const embeddings = await embedTexts(
|
||||
const embedded = await embedTexts(
|
||||
config,
|
||||
candidates.map((candidate) => candidate.content),
|
||||
opts.executionCounter,
|
||||
);
|
||||
usageReport = embedded.report;
|
||||
// Forward the embed usage before the save loop, so a billed embed
|
||||
// call is priced even when a later saveEntryWithSources throws. The
|
||||
// callback is best-effort: a throw or rejection never fails candidate
|
||||
// processing.
|
||||
if (embedded.report)
|
||||
reportSideCallUsage(opts.onUsage, embedded.report.model, embedded.report.usage);
|
||||
const embeddings = embedded.embeddings;
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
const saved = await opts.memory.episodic.saveEntryWithSources(
|
||||
{
|
||||
|
|
@ -346,7 +403,7 @@ async function saveCandidateEntries(
|
|||
};
|
||||
},
|
||||
);
|
||||
return savedEntries;
|
||||
return { entries: savedEntries, usageReport };
|
||||
}
|
||||
|
||||
async function runEpisodicMemoryReflection(
|
||||
|
|
@ -355,28 +412,52 @@ async function runEpisodicMemoryReflection(
|
|||
savedEntries: EpisodicMemoryEntry[],
|
||||
candidates: EpisodicMemoryCaptureCandidate[],
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
if (!config.reflect) return;
|
||||
): Promise<EpisodicMemoryUsageReport[]> {
|
||||
if (!config.reflect) return [];
|
||||
const cluster = await buildReflectionCluster(opts, config, savedEntries, candidates);
|
||||
if (cluster.length === 0) return;
|
||||
if (cluster.length === 0) return [];
|
||||
|
||||
const sources = await opts.memory.episodic.getEntrySources(cluster.map((entry) => entry.id));
|
||||
const reflection = normalizeEpisodicMemoryReflection(
|
||||
cluster,
|
||||
await config.reflect({
|
||||
scope: opts.scope,
|
||||
now,
|
||||
seedEntryIds: savedEntries.map((entry) => entry.id),
|
||||
entries: cluster,
|
||||
sources,
|
||||
executionCounter: opts.executionCounter,
|
||||
}),
|
||||
);
|
||||
if (reflection.drop.length === 0 && reflection.merge.length === 0) return;
|
||||
const reflectResult = await config.reflect({
|
||||
scope: opts.scope,
|
||||
now,
|
||||
seedEntryIds: savedEntries.map((entry) => entry.id),
|
||||
entries: cluster,
|
||||
sources,
|
||||
executionCounter: opts.executionCounter,
|
||||
});
|
||||
const reflectionResult =
|
||||
'reflection' in reflectResult
|
||||
? reflectResult
|
||||
: { reflection: reflectResult, usage: undefined, model: '' };
|
||||
const reports: EpisodicMemoryUsageReport[] = [];
|
||||
if (reflectionResult.usage !== undefined) {
|
||||
reports.push({
|
||||
task: 'episodic',
|
||||
model: reflectionResult.model,
|
||||
usage: reflectionResult.usage,
|
||||
});
|
||||
// Forward the reflect usage before applyReflection, so a billed
|
||||
// reflect call is priced even when a later merge embed or
|
||||
// applyReflection throws. The callback is best-effort: a throw or
|
||||
// rejection never fails reflection.
|
||||
reportSideCallUsage(opts.onUsage, reflectionResult.model, reflectionResult.usage);
|
||||
}
|
||||
const reflection = normalizeEpisodicMemoryReflection(cluster, reflectionResult.reflection);
|
||||
if (reflection.drop.length === 0 && reflection.merge.length === 0) return reports;
|
||||
|
||||
const mergeContents = reflection.merge.map((entry) => entry.content);
|
||||
const mergeEmbeddings =
|
||||
mergeContents.length > 0 ? await embedTexts(config, mergeContents, opts.executionCounter) : [];
|
||||
let mergeEmbeddings: number[][] = [];
|
||||
if (mergeContents.length > 0) {
|
||||
const mergeEmbedded = await embedTexts(config, mergeContents, opts.executionCounter);
|
||||
if (mergeEmbedded.report) {
|
||||
reports.push(mergeEmbedded.report);
|
||||
// Forward the merge-embed usage before applyReflection. Best-effort:
|
||||
// a callback throw or rejection never fails reflection.
|
||||
reportSideCallUsage(opts.onUsage, mergeEmbedded.report.model, mergeEmbedded.report.usage);
|
||||
}
|
||||
mergeEmbeddings = mergeEmbedded.embeddings;
|
||||
}
|
||||
await opts.memory.episodic.applyReflection(opts.scope, {
|
||||
drop: reflection.drop,
|
||||
merge: reflection.merge.map((merge, index) => ({
|
||||
|
|
@ -392,6 +473,7 @@ async function runEpisodicMemoryReflection(
|
|||
},
|
||||
})),
|
||||
});
|
||||
return reports;
|
||||
}
|
||||
|
||||
async function buildReflectionCluster(
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import { z } from 'zod';
|
|||
|
||||
import type {
|
||||
EpisodicMemoryEntrySource,
|
||||
EpisodicMemoryReflection,
|
||||
EpisodicMemoryReflectResult,
|
||||
EpisodicMemoryReflectorInput,
|
||||
EpisodicMemoryReflectFn,
|
||||
ModelConfig,
|
||||
RetrievedEpisodicMemoryEntry,
|
||||
} from '../../types';
|
||||
import { getModelIdString } from '../../utils/model';
|
||||
import { incrementTokenCountFromUsage } from '../loop/execution-counter';
|
||||
import { createModel } from '../model/model-factory';
|
||||
import { toTokenUsage } from '../streaming/stream';
|
||||
|
||||
export const DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL = 'openai/text-embedding-3-small';
|
||||
export const DEFAULT_EPISODIC_MEMORY_TOP_K = 5;
|
||||
|
|
@ -65,7 +67,7 @@ export function createEpisodicMemoryReflectFn(
|
|||
model: ModelConfig,
|
||||
options: CreateEpisodicMemoryReflectFnOptions = {},
|
||||
): EpisodicMemoryReflectFn {
|
||||
return async (input): Promise<EpisodicMemoryReflection> => {
|
||||
return async (input): Promise<EpisodicMemoryReflectResult> => {
|
||||
const { generateText, Output } = await import('ai');
|
||||
const response = await generateText({
|
||||
model: createModel(model),
|
||||
|
|
@ -74,7 +76,11 @@ export function createEpisodicMemoryReflectFn(
|
|||
output: Output.object({ schema: EpisodicMemoryReflectionSchema }),
|
||||
});
|
||||
incrementTokenCountFromUsage(input.executionCounter, response.usage);
|
||||
return response.output;
|
||||
return {
|
||||
reflection: response.output,
|
||||
usage: toTokenUsage(response.usage, response.providerMetadata),
|
||||
model: getModelIdString(model),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
25
packages/@n8n/agents/src/runtime/memory/forward-usage.ts
Normal file
25
packages/@n8n/agents/src/runtime/memory/forward-usage.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { TokenUsage } from '../../types/sdk/agent';
|
||||
|
||||
/**
|
||||
* Invoke a side-call usage callback (observer/reflector/episodic `onUsage`)
|
||||
* without letting it fail memory processing. Both failure modes are
|
||||
* swallowed: a synchronous throw is caught, and a rejected promise is
|
||||
* caught on the promise chain. Pricing is best-effort and must never abort a
|
||||
* memory task or surface an unhandled rejection.
|
||||
*/
|
||||
export function reportSideCallUsage(
|
||||
onUsage:
|
||||
| ((model: string | undefined, usage: TokenUsage | undefined) => void | Promise<void>)
|
||||
| undefined,
|
||||
model: string | undefined,
|
||||
usage: TokenUsage | undefined,
|
||||
): void {
|
||||
if (!onUsage) return;
|
||||
try {
|
||||
void Promise.resolve(onUsage(model, usage)).catch(() => {
|
||||
// best-effort: never surface a rejected pricing callback
|
||||
});
|
||||
} catch {
|
||||
// best-effort: never let a synchronous pricing throw fail memory processing
|
||||
}
|
||||
}
|
||||
|
|
@ -30,10 +30,18 @@ import type {
|
|||
EpisodicMemoryTaskLockMethods,
|
||||
} from '../../types';
|
||||
import { AgentEvent } from '../../types/runtime/event';
|
||||
import type { AgentPersistenceOptions, ExecutionOptions, RunOptions } from '../../types/sdk/agent';
|
||||
import type {
|
||||
AgentPersistenceOptions,
|
||||
ExecutionOptions,
|
||||
RunOptions,
|
||||
SideCallTask,
|
||||
SideCallUsageReport,
|
||||
TokenUsage,
|
||||
} from '../../types/sdk/agent';
|
||||
import type { AgentDbMessage } from '../../types/sdk/message';
|
||||
import type { ObservationLogScope, ObservationLogTaskKind } from '../../types/sdk/observation-log';
|
||||
import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime';
|
||||
import { computeSideCallCost } from '../loop/side-call-cost';
|
||||
import type { AgentMessageList } from '../model/message-list';
|
||||
import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter';
|
||||
import type { BackgroundTaskTracker } from '../state/background-task-tracker';
|
||||
|
|
@ -51,6 +59,9 @@ const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000;
|
|||
const MID_RUN_SOFT_THRESHOLD_RATIO = 0.7;
|
||||
const logger = createFilteredLogger();
|
||||
|
||||
/** Host callback that receives a priced side-call usage report to add to the session total. */
|
||||
type OnSideCallUsage = (report: SideCallUsageReport) => void | Promise<void>;
|
||||
|
||||
function stringifyForBudget(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? '';
|
||||
|
|
@ -222,7 +233,12 @@ export class MemoryOrchestrator {
|
|||
this.resetRunState();
|
||||
if (this.config.memory && options?.persistence?.threadId) {
|
||||
const telemetry = this.runtimeTelemetry.resolve(options);
|
||||
this.scheduleEpisodicMemoryJob(options.persistence, options.executionCounter, telemetry);
|
||||
this.scheduleEpisodicMemoryJob(
|
||||
options.persistence,
|
||||
options.executionCounter,
|
||||
telemetry,
|
||||
options.onSideCallUsage,
|
||||
);
|
||||
const memMessages = await this.loadHistoryMessages(options.persistence, telemetry);
|
||||
|
||||
if (memMessages.length > 0) {
|
||||
|
|
@ -289,10 +305,10 @@ export class MemoryOrchestrator {
|
|||
|
||||
async discardRejectedInput(
|
||||
list: AgentMessageList,
|
||||
messageIds: string[],
|
||||
options: (RunOptions & ExecutionOptions) | undefined,
|
||||
): Promise<void> {
|
||||
const messageIds = list.inputDelta().map((message) => message.id);
|
||||
list.removeInput();
|
||||
list.removeInput(messageIds);
|
||||
if (!this.config.memory || !options?.persistence || messageIds.length === 0) return;
|
||||
try {
|
||||
await this.config.memory.deleteMessages(messageIds);
|
||||
|
|
@ -438,9 +454,15 @@ export class MemoryOrchestrator {
|
|||
options.persistence,
|
||||
options.executionCounter,
|
||||
telemetry,
|
||||
options.onSideCallUsage,
|
||||
);
|
||||
if (hasSuccessfulMemoryFlag(list)) {
|
||||
this.scheduleEpisodicMemoryJob(options.persistence, options.executionCounter, telemetry);
|
||||
this.scheduleEpisodicMemoryJob(
|
||||
options.persistence,
|
||||
options.executionCounter,
|
||||
telemetry,
|
||||
options.onSideCallUsage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,6 +576,7 @@ export class MemoryOrchestrator {
|
|||
options.persistence,
|
||||
options.executionCounter,
|
||||
telemetry,
|
||||
options.onSideCallUsage,
|
||||
);
|
||||
if (!handle) return;
|
||||
const result = await handle.done;
|
||||
|
|
@ -570,6 +593,7 @@ export class MemoryOrchestrator {
|
|||
options.persistence,
|
||||
options.executionCounter,
|
||||
telemetry,
|
||||
options.onSideCallUsage,
|
||||
);
|
||||
if (!handle) return;
|
||||
const entry: MidRunObserverTask = { handle };
|
||||
|
|
@ -649,6 +673,7 @@ export class MemoryOrchestrator {
|
|||
persistence: AgentPersistenceOptions,
|
||||
executionCounter?: AgentExecutionCounter,
|
||||
telemetry?: BuiltTelemetry,
|
||||
onSideCallUsage?: OnSideCallUsage,
|
||||
): ScopedMemoryTaskHandle<RunObservationLogObserverResult> | undefined {
|
||||
const run = this.observerRun;
|
||||
if (run.disabled) return undefined;
|
||||
|
|
@ -674,6 +699,14 @@ export class MemoryOrchestrator {
|
|||
tokenCounter: this.tokenCounter,
|
||||
executionCounter,
|
||||
telemetry,
|
||||
// Usage is forwarded inside the observer right after the model
|
||||
// call (before persistence), so a billed observer turn is
|
||||
// priced even when post-processing throws.
|
||||
onUsage: onSideCallUsage
|
||||
? (model, usage) => {
|
||||
void this.forwardSideCallUsage(onSideCallUsage, 'observer', model, usage);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (result.status === 'ran' && !result.cursorAdvanced) run.disabled = true;
|
||||
return result;
|
||||
|
|
@ -690,6 +723,7 @@ export class MemoryOrchestrator {
|
|||
persistence: AgentPersistenceOptions,
|
||||
executionCounter?: AgentExecutionCounter,
|
||||
telemetry?: BuiltTelemetry,
|
||||
onSideCallUsage?: OnSideCallUsage,
|
||||
): Promise<void> {
|
||||
const { memory, observationalMemory } = this.config;
|
||||
if (!memory || !observationalMemory || !hasObservationLogStore(memory)) return;
|
||||
|
|
@ -709,7 +743,7 @@ export class MemoryOrchestrator {
|
|||
void midRunTask.handle.done.then(() => {
|
||||
if (this.midRunObserverTask === midRunTask) this.midRunObserverTask = undefined;
|
||||
});
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry);
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage);
|
||||
} else if (
|
||||
this.cursorAdvancedThisRun ||
|
||||
(midRunTask?.result !== undefined && didAdvanceCursor(midRunTask.result))
|
||||
|
|
@ -717,29 +751,34 @@ export class MemoryOrchestrator {
|
|||
// Mid-run advanced the cursor this run — either activated at a boundary
|
||||
// or settled after the last one: observe the tail regardless of the
|
||||
// visible-window budget, which the mask shrank below threshold.
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry);
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage);
|
||||
} else if (await this.shouldScheduleObserver(list, persistence.threadId)) {
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry);
|
||||
this.scheduleObserverTask(persistence, executionCounter, telemetry, onSideCallUsage);
|
||||
}
|
||||
|
||||
const reflect = observationalMemory.reflect;
|
||||
const reflectorThresholdTokens = observationalMemory.reflectorThresholdTokens;
|
||||
if (reflect && reflectorThresholdTokens !== undefined) {
|
||||
void this.scheduleMemoryTask(
|
||||
runner,
|
||||
scope,
|
||||
'reflector',
|
||||
async () =>
|
||||
await runObservationLogReflector({
|
||||
memory,
|
||||
...scope,
|
||||
reflectorThresholdTokens,
|
||||
reflect,
|
||||
tokenCounter: this.tokenCounter,
|
||||
executionCounter,
|
||||
telemetry,
|
||||
}),
|
||||
);
|
||||
void this.scheduleMemoryTask(runner, scope, 'reflector', async () => {
|
||||
const result = await runObservationLogReflector({
|
||||
memory,
|
||||
...scope,
|
||||
reflectorThresholdTokens,
|
||||
reflect,
|
||||
tokenCounter: this.tokenCounter,
|
||||
executionCounter,
|
||||
telemetry,
|
||||
// Usage is forwarded inside the reflector right after the model
|
||||
// call (before persistence), so a billed reflector turn is
|
||||
// priced even when post-processing throws.
|
||||
onUsage: onSideCallUsage
|
||||
? (model, usage) => {
|
||||
void this.forwardSideCallUsage(onSideCallUsage, 'reflector', model, usage);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -767,6 +806,7 @@ export class MemoryOrchestrator {
|
|||
persistence: AgentPersistenceOptions,
|
||||
executionCounter?: AgentExecutionCounter,
|
||||
telemetry?: BuiltTelemetry,
|
||||
onSideCallUsage?: OnSideCallUsage,
|
||||
): void {
|
||||
const capture = resolveEpisodicMemoryCapture(this.config, persistence);
|
||||
if (!capture) return;
|
||||
|
|
@ -779,6 +819,15 @@ export class MemoryOrchestrator {
|
|||
executionCounter,
|
||||
telemetry,
|
||||
agentName: this.config.name,
|
||||
// Each embed/reflect usage is forwarded inside the processor
|
||||
// the moment its model call resolves, before persistence, so a
|
||||
// billed turn is priced even when a later save or applyReflection
|
||||
// throws. Returning usageReports here would double-count.
|
||||
onUsage: onSideCallUsage
|
||||
? (model, usage) => {
|
||||
void this.forwardSideCallUsage(onSideCallUsage, 'episodic', model, usage);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} while (result.status === 'ran');
|
||||
});
|
||||
|
|
@ -888,4 +937,25 @@ export class MemoryOrchestrator {
|
|||
observationScopeId: persistence.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a side-call model turn and forward it to the host so it can add the
|
||||
* USD cost to the execution and thread totals. Best-effort: a catalog
|
||||
* lookup or host failure is logged and never fails the memory task.
|
||||
*/
|
||||
private async forwardSideCallUsage(
|
||||
onSideCallUsage: OnSideCallUsage | undefined,
|
||||
task: SideCallTask,
|
||||
model: string | undefined,
|
||||
usage: TokenUsage | undefined,
|
||||
): Promise<void> {
|
||||
if (!onSideCallUsage || !usage || !model) return;
|
||||
try {
|
||||
const cost = await computeSideCallCost(model, usage, this.config.promptCaching);
|
||||
if (cost === undefined) return;
|
||||
await onSideCallUsage({ task, model, usage, cost, reportId: crypto.randomUUID() });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to report side-call usage', { error, task });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,19 +158,18 @@ export function createObservationLogObserveFn(
|
|||
});
|
||||
incrementTokenCountFromUsage(input.executionCounter, usage);
|
||||
|
||||
if (options.onUsage) {
|
||||
const tokenUsage = toTokenUsage(usage, providerMetadata);
|
||||
if (tokenUsage) {
|
||||
await options.onUsage({
|
||||
task: 'observer',
|
||||
model: getModelIdString(model),
|
||||
usage: tokenUsage,
|
||||
reportId: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
const tokenUsage = toTokenUsage(usage, providerMetadata);
|
||||
const modelId = getModelIdString(model);
|
||||
if (options.onUsage && tokenUsage) {
|
||||
await options.onUsage({
|
||||
task: 'observer',
|
||||
model: modelId,
|
||||
usage: tokenUsage,
|
||||
reportId: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
return { text: text.trim(), usage: tokenUsage, model: modelId };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -326,16 +325,15 @@ export function createObservationLogReflectFn(
|
|||
});
|
||||
incrementTokenCountFromUsage(input.executionCounter, usage);
|
||||
|
||||
if (options.onUsage) {
|
||||
const tokenUsage = toTokenUsage(usage, providerMetadata);
|
||||
if (tokenUsage) {
|
||||
await options.onUsage({
|
||||
task: 'reflector',
|
||||
model: getModelIdString(model),
|
||||
usage: tokenUsage,
|
||||
reportId: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
const tokenUsage = toTokenUsage(usage, providerMetadata);
|
||||
const modelId = getModelIdString(model);
|
||||
if (options.onUsage && tokenUsage) {
|
||||
await options.onUsage({
|
||||
task: 'reflector',
|
||||
model: modelId,
|
||||
usage: tokenUsage,
|
||||
reportId: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
const reflection = parseObservationLogReflectionJson(text);
|
||||
|
|
@ -344,15 +342,19 @@ export function createObservationLogReflectFn(
|
|||
if (id === undefined) throw new Error(`Unknown observation reference: ${reference}`);
|
||||
return id;
|
||||
};
|
||||
return JSON.stringify({
|
||||
drop: reflection.drop.map(resolveId),
|
||||
merge: reflection.merge.map((merge) => ({
|
||||
...merge,
|
||||
supersedes: merge.supersedes.map(resolveId),
|
||||
...(merge.parentId !== undefined && {
|
||||
parentId: merge.parentId === null ? null : resolveId(merge.parentId),
|
||||
}),
|
||||
})),
|
||||
});
|
||||
return {
|
||||
text: JSON.stringify({
|
||||
drop: reflection.drop.map(resolveId),
|
||||
merge: reflection.merge.map((merge) => ({
|
||||
...merge,
|
||||
supersedes: merge.supersedes.map(resolveId),
|
||||
...(merge.parentId !== undefined && {
|
||||
parentId: merge.parentId === null ? null : resolveId(merge.parentId),
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
usage: tokenUsage,
|
||||
model: modelId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { isSensitiveKey } from '@n8n/utils/redaction/sensitive-key';
|
||||
|
||||
import { renderObservationLog } from './observation-log-renderer';
|
||||
import { reportSideCallUsage } from './forward-usage';
|
||||
import { redactText } from '../../sdk/guardrails';
|
||||
import type { AgentExecutionCounter } from '../../types/sdk/agent';
|
||||
import type { AgentExecutionCounter, TokenUsage } from '../../types/sdk/agent';
|
||||
import type { BuiltMemory } from '../../types/sdk/memory';
|
||||
import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message';
|
||||
import type { ObservationCursor } from '../../types/sdk/observation';
|
||||
|
|
@ -83,6 +84,13 @@ export interface RunObservationLogObserverOpts {
|
|||
onMalformedLine?: (line: string) => void;
|
||||
executionCounter?: AgentExecutionCounter;
|
||||
telemetry?: BuiltTelemetry;
|
||||
/**
|
||||
* Receives the observer model call's usage the moment it resolves, before
|
||||
* any parsing or persistence. Forwarding it here (rather than only on the
|
||||
* success return) keeps a billed observer call priced even when later
|
||||
* post-processing throws. Fire-and-forget from the caller's perspective.
|
||||
*/
|
||||
onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type RunObservationLogObserverResult =
|
||||
|
|
@ -93,6 +101,10 @@ export type RunObservationLogObserverResult =
|
|||
cursorAdvanced: boolean;
|
||||
tokenCount: number;
|
||||
skippedLines: string[];
|
||||
/** Normalized token usage from the observer LLM call, when the provider reports it. */
|
||||
usage?: TokenUsage;
|
||||
/** Stable model id string of the model that produced the observations. */
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export function parseObservationLogMarkdown(markdown: string): ParseObservationLogMarkdownResult {
|
||||
|
|
@ -217,7 +229,7 @@ export async function runObservationLogObserver(
|
|||
).reverse();
|
||||
const now = opts.now ?? new Date();
|
||||
const renderedObservationLogTail = renderObservationLog(observationLogTail);
|
||||
const markdown = await opts.observe({
|
||||
const observeResult = await opts.observe({
|
||||
observationScopeId,
|
||||
now,
|
||||
deltaMessages: observable,
|
||||
|
|
@ -228,6 +240,14 @@ export async function runObservationLogObserver(
|
|||
executionCounter: opts.executionCounter,
|
||||
telemetry: opts.telemetry,
|
||||
});
|
||||
const markdown = typeof observeResult === 'string' ? observeResult : observeResult.text;
|
||||
const observeUsage = typeof observeResult === 'string' ? undefined : observeResult.usage;
|
||||
const observeModel = typeof observeResult === 'string' ? undefined : observeResult.model;
|
||||
// Forward usage immediately after the model call, before parsing or
|
||||
// persistence, so a billed observer turn is priced even when the
|
||||
// post-processing below throws. Fire-and-forget: never block on pricing,
|
||||
// and never let a callback throw or rejection abort observation.
|
||||
reportSideCallUsage(opts.onUsage, observeModel, observeUsage);
|
||||
|
||||
const noObservations = markdown.trim() === 'NO_OBSERVATIONS';
|
||||
const parsed = noObservations
|
||||
|
|
@ -248,6 +268,8 @@ export async function runObservationLogObserver(
|
|||
cursorAdvanced: false,
|
||||
tokenCount,
|
||||
skippedLines: parsed.skippedLines,
|
||||
usage: observeUsage,
|
||||
model: observeModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +320,8 @@ export async function runObservationLogObserver(
|
|||
cursorAdvanced,
|
||||
tokenCount,
|
||||
skippedLines: parsed.skippedLines,
|
||||
usage: observeUsage,
|
||||
model: observeModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { extractJsonCandidate } from '@n8n/ai-utilities/llm-output';
|
|||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import { uniqueStrings } from './memory-lifecycle';
|
||||
import { reportSideCallUsage } from './forward-usage';
|
||||
import { redactText } from '../../sdk/guardrails';
|
||||
import type { AgentExecutionCounter } from '../../types/sdk/agent';
|
||||
import type { AgentExecutionCounter, TokenUsage } from '../../types/sdk/agent';
|
||||
import type {
|
||||
BuiltObservationLogStore,
|
||||
ObservationLogEntry,
|
||||
|
|
@ -49,6 +50,13 @@ export interface RunObservationLogReflectorOpts {
|
|||
onWarning?: (warning: ObservationLogReflectorWarning) => void;
|
||||
executionCounter?: AgentExecutionCounter;
|
||||
telemetry?: BuiltTelemetry;
|
||||
/**
|
||||
* Receives the reflector model call's usage the moment it resolves, before
|
||||
* any parsing or persistence. Forwarding it here (rather than only on the
|
||||
* success return) keeps a billed reflector call priced even when later
|
||||
* post-processing throws. Fire-and-forget from the caller's perspective.
|
||||
*/
|
||||
onUsage?: (model: string | undefined, usage: TokenUsage | undefined) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type RunObservationLogReflectorResult =
|
||||
|
|
@ -60,6 +68,10 @@ export type RunObservationLogReflectorResult =
|
|||
overBudgetAfterReflection: boolean;
|
||||
reflection: ObservationLogReflection;
|
||||
result: ObservationLogReflectionResult;
|
||||
/** Normalized token usage from the reflector LLM call, when the provider reports it. */
|
||||
usage?: TokenUsage;
|
||||
/** Stable model id string of the model that produced the reflection. */
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export function parseObservationLogReflectionJson(output: string): ObservationLogReflection {
|
||||
|
|
@ -180,7 +192,7 @@ export async function runObservationLogReflector(
|
|||
|
||||
const now = opts.now ?? new Date();
|
||||
const renderedObservationLog = renderObservationLogForReflection(activeObservationLog);
|
||||
const output = await opts.reflect({
|
||||
const reflectResult = await opts.reflect({
|
||||
observationScopeId,
|
||||
now,
|
||||
activeObservationLog,
|
||||
|
|
@ -190,6 +202,14 @@ export async function runObservationLogReflector(
|
|||
executionCounter: opts.executionCounter,
|
||||
telemetry: opts.telemetry,
|
||||
});
|
||||
const output = typeof reflectResult === 'string' ? reflectResult : reflectResult.text;
|
||||
const reflectUsage = typeof reflectResult === 'string' ? undefined : reflectResult.usage;
|
||||
const reflectModel = typeof reflectResult === 'string' ? undefined : reflectResult.model;
|
||||
// Forward usage immediately after the model call, before parsing or
|
||||
// persistence, so a billed reflector turn is priced even when the
|
||||
// post-processing below throws. Fire-and-forget: never block on pricing,
|
||||
// and never let a callback throw or rejection abort reflection.
|
||||
reportSideCallUsage(opts.onUsage, reflectModel, reflectUsage);
|
||||
const normalized = normalizeObservationLogReflection(
|
||||
activeObservationLog,
|
||||
withCreatedAt(parseObservationLogReflectionJson(output), now),
|
||||
|
|
@ -225,6 +245,8 @@ export async function runObservationLogReflector(
|
|||
overBudgetAfterReflection,
|
||||
reflection,
|
||||
result,
|
||||
usage: reflectUsage,
|
||||
model: reflectModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import type { LanguageModel } from 'ai';
|
||||
|
||||
import type { BuiltMemory, BuiltTelemetry, TitleGenerationConfig } from '../../types';
|
||||
import type { AgentExecutionCounter, ModelConfig } from '../../types/sdk/agent';
|
||||
import type {
|
||||
AgentExecutionCounter,
|
||||
ModelConfig,
|
||||
PromptCachingConfig,
|
||||
SideCallUsageReport,
|
||||
TokenUsage,
|
||||
} from '../../types/sdk/agent';
|
||||
import type { AgentDbMessage } from '../../types/sdk/message';
|
||||
import { getModelIdString } from '../../utils/model';
|
||||
import { createFilteredLogger } from '../logger';
|
||||
import { incrementTokenCountFromUsage } from '../loop/execution-counter';
|
||||
import { computeSideCallCost } from '../loop/side-call-cost';
|
||||
import { loadAi } from '../model/lazy-ai';
|
||||
import { createModel, type FetchFn } from '../model/model-factory';
|
||||
import { toTokenUsage } from '../streaming/stream';
|
||||
import { buildAiSdkTelemetry } from '../telemetry/telemetry-options';
|
||||
|
||||
const logger = createFilteredLogger();
|
||||
|
|
@ -144,13 +153,16 @@ export async function generateTitleFromMessage(
|
|||
* it; falls back to treating the whole response as a plain title if the model
|
||||
* ignores the JSON format.
|
||||
*
|
||||
* Returns `null` on empty/trivial input or empty LLM output.
|
||||
* Returns `null` only on empty/trivial input (no model call is made). When the
|
||||
* model is called, returns an object with `usage` and `title` set to `null`
|
||||
* when no usable title could be extracted, so the caller can still price the
|
||||
* billed turn.
|
||||
*/
|
||||
export async function generateTitleAndEmojiFromMessage(
|
||||
model: LanguageModel,
|
||||
userMessage: string,
|
||||
opts?: { instructions?: string; executionCounter?: AgentExecutionCounter },
|
||||
): Promise<{ title: string; emoji?: string } | null> {
|
||||
): Promise<{ title: string | null; emoji?: string; usage?: TokenUsage } | null> {
|
||||
const trimmed = userMessage.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
|
|
@ -170,13 +182,14 @@ ${trimmed}
|
|||
messages: [{ role: 'user', content: wrappedMessage }],
|
||||
});
|
||||
incrementTokenCountFromUsage(opts?.executionCounter, result.usage);
|
||||
const usage = toTokenUsage(result.usage, result.providerMetadata);
|
||||
|
||||
let text = result.text?.trim();
|
||||
if (!text) return null;
|
||||
if (!text) return { title: null, usage };
|
||||
|
||||
// Strip <think>...</think> blocks (e.g. from DeepSeek R1) before JSON parsing.
|
||||
text = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
if (!text) return null;
|
||||
if (!text) return { title: null, usage };
|
||||
|
||||
let rawTitle = '';
|
||||
let emoji: string | undefined;
|
||||
|
|
@ -197,9 +210,9 @@ ${trimmed}
|
|||
}
|
||||
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
if (!title) return null;
|
||||
if (!title) return { title: null, emoji, usage };
|
||||
|
||||
return { title, emoji };
|
||||
return { title, emoji, usage };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,6 +234,13 @@ export async function generateThreadTitle(opts: {
|
|||
/** Messages from the current turn, used to find the first user message. */
|
||||
turnDelta: AgentDbMessage[];
|
||||
executionCounter?: AgentExecutionCounter;
|
||||
/**
|
||||
* Host callback that receives the priced title-generation usage so it can
|
||||
* add the USD cost to the execution and thread totals. Best-effort.
|
||||
*/
|
||||
onSideCallUsage?: (report: SideCallUsageReport) => void | Promise<void>;
|
||||
/** Agent prompt-caching config, used to resolve Anthropic cache-write pricing for the title model. */
|
||||
promptCaching?: PromptCachingConfig;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const thread = await opts.memory.getThread(opts.threadId);
|
||||
|
|
@ -243,7 +263,27 @@ export async function generateThreadTitle(opts: {
|
|||
});
|
||||
if (!generated) return;
|
||||
|
||||
const { title, emoji } = generated;
|
||||
const { title, emoji, usage } = generated;
|
||||
|
||||
// Price the title model turn and forward it to the host so it adds the
|
||||
// cost to the execution and thread totals. Report immediately after the
|
||||
// model call — independent of whether a usable title was extracted or
|
||||
// later persistence succeeds — so a billed turn is never left unpriced.
|
||||
// Best-effort: a catalog lookup or host failure is logged and never
|
||||
// breaks title generation. Awaited (not fire-and-forget) so the cost is
|
||||
// captured deterministically before generateThreadTitle resolves, while
|
||||
// still best-effort: reportTitleUsage swallows its own errors.
|
||||
if (usage && opts.onSideCallUsage) {
|
||||
await reportTitleUsage(
|
||||
opts.onSideCallUsage,
|
||||
getModelIdString(titleModelId),
|
||||
usage,
|
||||
opts.promptCaching,
|
||||
);
|
||||
}
|
||||
|
||||
// No usable title — the model call was still billed (reported above).
|
||||
if (!title) return;
|
||||
|
||||
// Store emoji in thread metadata
|
||||
const metadata = { ...(thread?.metadata ?? {}), ...(emoji && { emoji }) };
|
||||
|
|
@ -258,3 +298,28 @@ export async function generateThreadTitle(opts: {
|
|||
logger.warn('Failed to generate thread title', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a title-generation model turn and forward it to the host. Best-effort:
|
||||
* a catalog lookup or host failure is logged and never breaks title generation.
|
||||
*/
|
||||
async function reportTitleUsage(
|
||||
onSideCallUsage: (report: SideCallUsageReport) => void | Promise<void>,
|
||||
modelId: string,
|
||||
usage: TokenUsage,
|
||||
promptCaching?: PromptCachingConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cost = await computeSideCallCost(modelId, usage, promptCaching);
|
||||
if (cost === undefined) return;
|
||||
await onSideCallUsage({
|
||||
task: 'title',
|
||||
model: modelId,
|
||||
usage,
|
||||
cost,
|
||||
reportId: crypto.randomUUID(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to report title generation usage', { error });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -426,9 +426,13 @@ export class AgentMessageList {
|
|||
return this.all.filter((m) => this.inputSet.has(m));
|
||||
}
|
||||
|
||||
removeInput(): void {
|
||||
this.all = this.all.filter((message) => !this.inputSet.has(message));
|
||||
this.inputSet.clear();
|
||||
removeInput(messageIds: readonly string[]): void {
|
||||
const ids = new Set(messageIds);
|
||||
this.all = this.all.filter((message) => {
|
||||
if (!this.inputSet.has(message) || !ids.has(message.id)) return true;
|
||||
this.inputSet.delete(message);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** All messages currently in the list, as live references. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { StreamChunk } from '../../../types';
|
||||
import { StreamWriterGuard } from '../stream-writer-guard';
|
||||
|
||||
describe('StreamWriterGuard', () => {
|
||||
it.each(['close', 'abort'] as const)(
|
||||
'marks the stream closed after writer.%s()',
|
||||
async (action) => {
|
||||
const writer = new WritableStream<StreamChunk>().getWriter();
|
||||
const guard = new StreamWriterGuard(writer);
|
||||
|
||||
expect(guard.isClosed).toBe(false);
|
||||
expect(guard.closedSignal.aborted).toBe(false);
|
||||
|
||||
await Promise.allSettled([writer[action](), writer.closed]);
|
||||
|
||||
expect(guard.isClosed).toBe(true);
|
||||
expect(guard.closedSignal.aborted).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -11,15 +11,20 @@ import type { TokenUsage } from '../../types/sdk/agent';
|
|||
*/
|
||||
export class StreamWriterGuard {
|
||||
private closed = false;
|
||||
private readonly closure = new AbortController();
|
||||
|
||||
private errorWritten = false;
|
||||
|
||||
constructor(private readonly writer: WritableStreamDefaultWriter<StreamChunk>) {
|
||||
writer.closed
|
||||
.then(() => {
|
||||
this.closed = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
const close = () => {
|
||||
this.closed = true;
|
||||
this.closure.abort(new Error('Agent stream closed'));
|
||||
};
|
||||
void writer.closed.then(close, close);
|
||||
}
|
||||
|
||||
get closedSignal(): AbortSignal {
|
||||
return this.closure.signal;
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
|
|
|
|||
|
|
@ -46,8 +46,11 @@ export type {
|
|||
ModelConfig,
|
||||
RunOptions,
|
||||
ExecutionOptions,
|
||||
AgentInputBoundary,
|
||||
SmoothStreamOptions,
|
||||
AgentExecutionCounter,
|
||||
SideCallTask,
|
||||
SideCallUsageReport,
|
||||
PersistedExecutionOptions,
|
||||
ResumeOptions,
|
||||
GenerateResult,
|
||||
|
|
@ -91,6 +94,7 @@ export type {
|
|||
EpisodicMemoryMethods,
|
||||
EpisodicMemoryPrompts,
|
||||
EpisodicMemoryReflectFn,
|
||||
EpisodicMemoryReflectResult,
|
||||
EpisodicMemoryReflection,
|
||||
EpisodicMemoryReflectionApply,
|
||||
EpisodicMemoryReflectionApplyMerge,
|
||||
|
|
@ -138,9 +142,11 @@ export type {
|
|||
ObservationLogMerge,
|
||||
MemoryTaskUsageReport,
|
||||
ObservationLogObserveFn,
|
||||
ObservationLogObserveResult,
|
||||
ObservationLogObserverInput,
|
||||
ObservationLogReadOptions,
|
||||
ObservationLogReflectFn,
|
||||
ObservationLogReflectResult,
|
||||
ObservationLogReflectorInput,
|
||||
ObservationLogReflection,
|
||||
ObservationLogReflectionResult,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
|
||||
import type { FinishReason, SerializableAgentState, TokenUsage } from '../index';
|
||||
import type { ExecutionOptions, RunOptions } from '../sdk/agent';
|
||||
import type { AgentMessage } from '../sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage } from '../sdk/message';
|
||||
import type { JSONObject } from '../utils/json';
|
||||
import type { AgentMessageList } from '../../runtime/model/message-list';
|
||||
import type { ToolCallBatchResult, ToolCallInput, ToolCallSuspension } from './tool-execution';
|
||||
|
|
@ -113,6 +113,9 @@ export interface RunServices {
|
|||
* `StreamChunk`s through the stream writer guard.
|
||||
*/
|
||||
export interface RunOutputSink<TResult> {
|
||||
/** Wait until the host has recorded preceding output before it commits input. */
|
||||
inputBoundary?(signal: AbortSignal): Promise<void>;
|
||||
emitInput?(message: AgentDbMessage): Promise<void>;
|
||||
/** Run one LLM turn. Streaming implementations also emit text/tool chunks here. */
|
||||
callModel(ctx: ModelCallContext): Promise<ModelTurnResult>;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
} from 'ai';
|
||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
||||
|
||||
import type { AgentMessage, ContentMetadata } from './message';
|
||||
import type { AgentDbMessage, AgentMessage, ContentMetadata } from './message';
|
||||
import type { ProviderId, ProviderCredentials } from '../../runtime/model/provider-credentials';
|
||||
import type {
|
||||
AgentEvent,
|
||||
|
|
@ -90,6 +90,8 @@ export interface AgentResult {
|
|||
|
||||
export type StreamChunk = ContentMetadata &
|
||||
(
|
||||
| { type: 'input-boundary'; acknowledge: () => void }
|
||||
| { type: 'input'; message: AgentDbMessage }
|
||||
| { type: 'start-step' }
|
||||
| { type: 'finish-step' }
|
||||
| { type: 'text-start'; id: string }
|
||||
|
|
@ -184,7 +186,40 @@ export interface AgentExecutionCounter {
|
|||
incrementTokenCount(tokenCount: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind of background model call whose cost is added to the session total
|
||||
* outside the main agent loop. Title generation, observation-log
|
||||
* observer/reflector, and episodic-memory model calls all bill here.
|
||||
*/
|
||||
export type SideCallTask = 'title' | 'observer' | 'reflector' | 'episodic';
|
||||
|
||||
/**
|
||||
* Reported after a side-call model turn completes, so the host can add the
|
||||
* estimated USD cost to the execution and thread totals. The `reportId` is
|
||||
* minted once per call so hosts can dedupe idempotently.
|
||||
*/
|
||||
export interface SideCallUsageReport {
|
||||
task: SideCallTask;
|
||||
/** Stable model id string (e.g. 'anthropic/claude-sonnet-4-5'). */
|
||||
model: string;
|
||||
usage: TokenUsage;
|
||||
/** Estimated cost in USD, computed from models.dev pricing. */
|
||||
cost: number;
|
||||
/** Stable per-LLM-call id, generated once at the call site for idempotent billing. */
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export interface AgentInputBoundary {
|
||||
/** Current turn messages, ready for durable storage. */
|
||||
messages: AgentDbMessage[];
|
||||
lastCreatedAt: number;
|
||||
completing: boolean;
|
||||
canContinue: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionOptions {
|
||||
/** Commit additional input between model calls. Stream consumers must acknowledge input-boundary chunks. */
|
||||
onInputBoundary?: (boundary: AgentInputBoundary) => Promise<AgentDbMessage[]>;
|
||||
maxIterations?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
providerOptions?: ProviderOptions;
|
||||
|
|
@ -224,6 +259,14 @@ export interface ExecutionOptions {
|
|||
telemetry?: BuiltTelemetry;
|
||||
/** Inherited execution counter from the host runtime. Used for aggregate heartbeat telemetry. */
|
||||
executionCounter?: AgentExecutionCounter;
|
||||
/**
|
||||
* Reported after a side-call model turn (title generation, observation-log
|
||||
* observer/reflector, episodic-memory model calls) completes, so the host
|
||||
* can add the estimated USD cost to the execution and thread totals. The
|
||||
* runtime computes the cost via the model catalog; the host only adds it.
|
||||
* Best-effort: a host failure here must not break the run.
|
||||
*/
|
||||
onSideCallUsage?: (report: SideCallUsageReport) => void | Promise<void>;
|
||||
onStepStart?: (event: GenerateTextStepStartEvent) => void | Promise<void>;
|
||||
onStepEnd?: (event: GenerateTextStepEndEvent) => void | Promise<void>;
|
||||
/** @deprecated Use `onStepEnd` instead. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { EmbeddingModel } from 'ai';
|
||||
|
||||
import type { AgentExecutionCounter, ModelConfig, SerializableAgentState } from './agent';
|
||||
import type {
|
||||
AgentExecutionCounter,
|
||||
ModelConfig,
|
||||
SerializableAgentState,
|
||||
TokenUsage,
|
||||
} from './agent';
|
||||
import type { AgentDbMessage } from './message';
|
||||
import type {
|
||||
BuiltObservationLogStore,
|
||||
|
|
@ -256,9 +261,23 @@ export interface EpisodicMemoryReflectorInput {
|
|||
executionCounter?: AgentExecutionCounter;
|
||||
}
|
||||
|
||||
export interface EpisodicMemoryReflectResult {
|
||||
reflection: EpisodicMemoryReflection;
|
||||
/** Normalized token usage from the reflector LLM call, when the provider reports it. */
|
||||
usage?: TokenUsage;
|
||||
/** Stable model id string of the model that produced the reflection. */
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect a cluster of episodic entries. Returns the reflection, or a
|
||||
* `{ reflection, usage, model }` result that also carries token usage for the
|
||||
* host to price. Bare `EpisodicMemoryReflection` returns are accepted for
|
||||
* backward compatibility.
|
||||
*/
|
||||
export type EpisodicMemoryReflectFn = (
|
||||
input: EpisodicMemoryReflectorInput,
|
||||
) => Promise<EpisodicMemoryReflection>;
|
||||
) => Promise<EpisodicMemoryReflection | EpisodicMemoryReflectResult>;
|
||||
|
||||
export interface EpisodicMemoryReflectionApplyMerge {
|
||||
supersedes: string[];
|
||||
|
|
|
|||
|
|
@ -88,7 +88,22 @@ export interface ObservationLogObserverInput {
|
|||
}
|
||||
|
||||
/** Return observation bullets, or exactly NO_OBSERVATIONS when the batch has no new facts. */
|
||||
export type ObservationLogObserveFn = (input: ObservationLogObserverInput) => Promise<string>;
|
||||
export interface ObservationLogObserveResult {
|
||||
text: string;
|
||||
/** Normalized token usage from the observer LLM call, when the provider reports it. */
|
||||
usage?: TokenUsage;
|
||||
/** Stable model id string of the model that produced the result. */
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the transcript delta. Returns the observation markdown, or a
|
||||
* `{ text, usage, model }` result that also carries token usage for the host
|
||||
* to price. String returns are accepted for backward compatibility.
|
||||
*/
|
||||
export type ObservationLogObserveFn = (
|
||||
input: ObservationLogObserverInput,
|
||||
) => Promise<string | ObservationLogObserveResult>;
|
||||
|
||||
export interface ObservationLogReflectorInput {
|
||||
observationScopeId: string;
|
||||
|
|
@ -101,7 +116,23 @@ export interface ObservationLogReflectorInput {
|
|||
telemetry?: BuiltTelemetry;
|
||||
}
|
||||
|
||||
export type ObservationLogReflectFn = (input: ObservationLogReflectorInput) => Promise<string>;
|
||||
export interface ObservationLogReflectResult {
|
||||
/** JSON string with `drop` and `merge` arrays, using the references supplied in the input. */
|
||||
text: string;
|
||||
/** Normalized token usage from the reflector LLM call, when the provider reports it. */
|
||||
usage?: TokenUsage;
|
||||
/** Stable model id string of the model that produced the result. */
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect the active observation log. Returns the reflection JSON, or a
|
||||
* `{ text, usage, model }` result that also carries token usage for the host
|
||||
* to price. String returns are accepted for backward compatibility.
|
||||
*/
|
||||
export type ObservationLogReflectFn = (
|
||||
input: ObservationLogReflectorInput,
|
||||
) => Promise<string | ObservationLogReflectResult>;
|
||||
|
||||
/** Reported after an observation-log observer/reflector LLM call completes, for hosts that meter usage. */
|
||||
export interface MemoryTaskUsageReport {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { dirname, resolve } from 'path';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { profilingReporters } from '@n8n/vitest-config/node';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
loadEnv({ path: resolve(__dirname, '.env'), quiet: true });
|
||||
|
|
@ -10,6 +11,7 @@ export default defineConfig({
|
|||
test: {
|
||||
include: ['src/__tests__/integration/**/*.test.ts'],
|
||||
setupFiles: ['vitest.integration.setup.ts'],
|
||||
reporters: profilingReporters(['default']),
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.31.0",
|
||||
"version": "0.32.0",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -5,14 +5,58 @@ export default defineConfig(
|
|||
backendConfig,
|
||||
globalIgnores(['scripts/**']),
|
||||
{
|
||||
files: ['src/utils/n8n-binary-loader.ts'],
|
||||
rules: { 'no-case-declarations': 'warn' },
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/__tests__/adapters/langchain-chat-model.test.ts',
|
||||
'src/__tests__/suppliers/supplyModel.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts',
|
||||
'src/__tests__/utils/n8n-llm-tracing.test.ts',
|
||||
'src/adapters/langchain-chat-model.ts',
|
||||
'src/types/message.ts',
|
||||
'src/types/tool.ts',
|
||||
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts',
|
||||
'src/utils/log-wrapper.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/*.test.ts',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'no-case-declarations': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Debt: the base layer enforces kebab-case filenames and this package has
|
||||
// 31 files that predate it. Rename them, then delete this block.
|
||||
files: [
|
||||
'src/__tests__/suppliers/supplyModel.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts',
|
||||
'src/suppliers/supplyMemory.ts',
|
||||
'src/suppliers/supplyModel.ts',
|
||||
'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts',
|
||||
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts',
|
||||
'src/utils/vector-store/MemoryManager/MemoryCalculator.ts',
|
||||
'src/utils/vector-store/MemoryManager/MemoryVectorStoreManager.ts',
|
||||
'src/utils/vector-store/MemoryManager/StoreCleanupService.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/MemoryCalculator.test.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/MemoryVectorStoreManager.test.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/StoreCleanupService.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/insertOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/loadOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/operationHandlers.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/updateOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/insertOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/loadOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/updateOperation.ts',
|
||||
'src/utils/vector-store/processDocuments.ts',
|
||||
],
|
||||
rules: {
|
||||
'unicorn/filename-case': 'off',
|
||||
},
|
||||
|
|
|
|||
63
packages/@n8n/ai-utilities/oxlint.config.mts
Normal file
63
packages/@n8n/ai-utilities/oxlint.config.mts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { backendConfig } from '@n8n/oxlint-config/backend';
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
export default defineConfig({
|
||||
extends: [backendConfig],
|
||||
options: { typeAware: true },
|
||||
ignorePatterns: ['scripts/**'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/utils/n8n-binary-loader.ts'],
|
||||
rules: { 'no-case-declarations': 'warn' },
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/__tests__/adapters/langchain-chat-model.test.ts',
|
||||
'src/__tests__/suppliers/supplyModel.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts',
|
||||
'src/__tests__/utils/n8n-llm-tracing.test.ts',
|
||||
'src/adapters/langchain-chat-model.ts',
|
||||
'src/types/message.ts',
|
||||
'src/types/tool.ts',
|
||||
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts',
|
||||
'src/utils/log-wrapper.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/*.test.ts',
|
||||
],
|
||||
rules: { 'typescript/no-explicit-any': 'warn' },
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/__tests__/suppliers/supplyModel.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts',
|
||||
'src/__tests__/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts',
|
||||
'src/suppliers/supplyMemory.ts',
|
||||
'src/suppliers/supplyModel.ts',
|
||||
'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts',
|
||||
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts',
|
||||
'src/utils/vector-store/MemoryManager/MemoryCalculator.ts',
|
||||
'src/utils/vector-store/MemoryManager/MemoryVectorStoreManager.ts',
|
||||
'src/utils/vector-store/MemoryManager/StoreCleanupService.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/MemoryCalculator.test.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/MemoryVectorStoreManager.test.ts',
|
||||
'src/utils/vector-store/MemoryManager/test/StoreCleanupService.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/createVectorStoreNode.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/insertOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/loadOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/operationHandlers.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveAsToolOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/retrieveOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/__tests__/updateOperation.test.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/insertOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/loadOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveAsToolOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/retrieveOperation.ts',
|
||||
'src/utils/vector-store/createVectorStoreNode/operations/updateOperation.ts',
|
||||
'src/utils/vector-store/processDocuments.ts',
|
||||
],
|
||||
rules: { 'unicorn/filename-case': 'off' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
@ -122,8 +122,8 @@
|
|||
"build:unchecked": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --noCheck && tsc-alias -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.cjs.json && pnpm copy-tokenizer-json dist/cjs && pnpm copy-tokenizer-json dist/esm",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "oxlint --quiet",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"watch": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
|
|
@ -133,6 +133,7 @@
|
|||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@n8n/oxlint-config": "workspace:*",
|
||||
"@types/json-schema": "catalog:",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
|
|
@ -145,7 +146,9 @@
|
|||
"tsx": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
"axios": "catalog:",
|
||||
"nock": "catalog:"
|
||||
"nock": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
|
|
|
|||
26
packages/@n8n/ai-workflow-builder.ee/oxlint.config.mts
Normal file
26
packages/@n8n/ai-workflow-builder.ee/oxlint.config.mts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { backendConfig } from '@n8n/oxlint-config/backend';
|
||||
import { defineConfig } from 'oxlint';
|
||||
|
||||
export default defineConfig({
|
||||
extends: [backendConfig],
|
||||
options: { typeAware: true },
|
||||
ignorePatterns: ['coverage/**', 'vitest.config.*.ts', 'evaluations/programmatic/python/.venv/**'],
|
||||
rules: {
|
||||
complexity: 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['./src/test/**/*.ts', './**/*.test.ts'],
|
||||
rules: {
|
||||
'typescript/no-unsafe-assignment': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./evaluations/**/*.ts'],
|
||||
jsPlugins: ['@n8n/oxlint-config/import-x-alias'],
|
||||
rules: {
|
||||
'import-x-alias/no-extraneous-dependencies': ['error', { devDependencies: true }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.41.0",
|
||||
"version": "1.42.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
@ -21,8 +21,8 @@
|
|||
"test:eval:coverage": "vitest run evaluations --coverage",
|
||||
"test:no-eval": "vitest run --exclude '**/evaluations/**'",
|
||||
"test:no-eval:coverage": "vitest run --exclude '**/evaluations/**' --coverage",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "oxlint --quiet",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
|
||||
"deps:graph": "madge src/index.ts --image deps-graph.svg",
|
||||
"deps:graph:service": "madge src/ai-workflow-builder-agent.service.ts --image deps-service.svg",
|
||||
|
|
@ -85,6 +85,7 @@
|
|||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/oxlint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@types/jsdom": "catalog:",
|
||||
|
|
@ -92,6 +93,8 @@
|
|||
"@types/turndown": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"madge": "^8.0.0",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
"vite": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.41.0",
|
||||
"version": "1.42.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
* - `start-step` / `finish-step` mark LLM iteration boundaries.
|
||||
*
|
||||
* The frontend groups deltas by these ids and uses `start-step` / `finish-step`
|
||||
* to decide when a new ChatMessage cursor should open. There is no
|
||||
* server-minted `messageId` — the FE generates its own UUID per ChatMessage
|
||||
* for v-for keys only.
|
||||
* to decide when a new ChatMessage cursor should open. The frontend assigns
|
||||
* display IDs to assistant messages. `execution-started` and `message-steered`
|
||||
* carry canonical input IDs for reconciliation with history.
|
||||
*
|
||||
* `runId` is included on `ToolSuspendedPayload` and echoed back by the
|
||||
* frontend on resume. The SDK stores `runId` on each `PendingToolCall` and
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import type { AgentPersistedMessageContentPart } from './agents';
|
||||
import type { AgentPersistedMessageContentPart, AgentPersistedMessageDto } from './agents';
|
||||
|
||||
export interface ToolSuspendedPayload {
|
||||
toolCallId: string;
|
||||
|
|
@ -73,6 +73,12 @@ export type ForwardedChildChunkWire =
|
|||
};
|
||||
|
||||
export type AgentSseEvent =
|
||||
| {
|
||||
type: 'message-steered';
|
||||
queueId: string;
|
||||
executionId: string;
|
||||
message: AgentPersistedMessageDto;
|
||||
}
|
||||
| { type: 'message-queued'; queueId: string; sessionId: string }
|
||||
| {
|
||||
type: 'execution-started';
|
||||
|
|
|
|||
|
|
@ -21,6 +21,22 @@ describe('AgentChatMessageDto', () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a session when a valid client message UUID is supplied', () => {
|
||||
const messageId = 'C4B02D7B-2088-41CE-9C6B-FAF8C7B83D8A';
|
||||
const payload = { message: 'Hello', sessionId: 'session-1', messageId };
|
||||
expect(AgentChatMessageDto.parse(payload)).toEqual(payload);
|
||||
expect(new AgentChatMessageDto(payload)).toMatchObject(payload);
|
||||
for (const invalid of [
|
||||
{ ...payload, sessionId: undefined },
|
||||
{ ...payload, sessionId: '' },
|
||||
{ ...payload, messageId: 'invalid' },
|
||||
{ ...payload, messageId: '' },
|
||||
]) {
|
||||
expect(AgentChatMessageDto.safeParse(invalid).success).toBe(false);
|
||||
expect(() => AgentChatMessageDto.parse(invalid)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a payload with neither message text nor attachments', () => {
|
||||
expect(AgentChatMessageDto.safeParse({ message: '' }).success).toBe(false);
|
||||
expect(AgentChatMessageDto.safeParse({ message: ' ' }).success).toBe(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSlackManagerCredentialReady } from '../slack/manager-credential';
|
||||
|
||||
describe('isSlackManagerCredentialReady', () => {
|
||||
it.each([
|
||||
[{ connected: true, reconnectRequired: false }, true],
|
||||
[{ connected: true, reconnectRequired: true }, false],
|
||||
[{ connected: false, reconnectRequired: false }, false],
|
||||
])('returns the readiness of %o', (credential, expected) => {
|
||||
expect(isSlackManagerCredentialReady(credential)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -97,13 +97,15 @@ export class ListAgentSessionsQueryDto extends Z.class({
|
|||
previewOnly: booleanFromString.optional(),
|
||||
status: z.enum(AGENT_SESSION_STATUSES).optional(),
|
||||
origin: z.enum(AGENT_SESSION_ORIGINS).optional(),
|
||||
/** `mine` keeps only the sessions the requesting user owns. */
|
||||
scope: z.enum(['all', 'mine']).optional(),
|
||||
updatedAfter: z.coerce.date().optional(),
|
||||
updatedBefore: z.coerce.date().optional(),
|
||||
}) {}
|
||||
|
||||
export type AgentSessionQueryFilters = Pick<
|
||||
ListAgentSessionsQueryDto,
|
||||
'status' | 'origin' | 'updatedAfter' | 'updatedBefore' | 'previewOnly'
|
||||
'status' | 'origin' | 'scope' | 'updatedAfter' | 'updatedBefore' | 'previewOnly'
|
||||
>;
|
||||
|
||||
export class AgentProviderModelsQueryDto extends Z.class({
|
||||
|
|
@ -227,6 +229,7 @@ const agentChatMessageShape = {
|
|||
// (attachment-only sends) — see the schema-level refinement below.
|
||||
message: z.string(),
|
||||
sessionId: z.string().min(1).optional(),
|
||||
messageId: z.string().uuid().optional(),
|
||||
newSession: z.literal(true).optional(),
|
||||
attachments: z
|
||||
.array(agentChatAttachmentSchema)
|
||||
|
|
@ -239,6 +242,10 @@ const agentChatMessageSchema = z
|
|||
.refine((value) => value.message.trim().length > 0 || (value.attachments?.length ?? 0) > 0, {
|
||||
message: 'Message text or at least one attachment is required',
|
||||
path: ['message'],
|
||||
})
|
||||
.refine((value) => !value.messageId || !!value.sessionId, {
|
||||
message: 'A session ID is required with a message ID',
|
||||
path: ['sessionId'],
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -265,6 +272,10 @@ export class AgentChatQueueUpdateDto extends Z.class({
|
|||
message: z.string(),
|
||||
}) {}
|
||||
|
||||
export class AgentChatQueueSteerDto extends Z.class({
|
||||
executionId: z.string().min(1).max(36),
|
||||
}) {}
|
||||
|
||||
export class AgentChatResumeDto extends Z.class({
|
||||
runId: z.string().min(1),
|
||||
toolCallId: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './dto';
|
||||
export * from './manager-credential';
|
||||
export type * from './types';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import type { SlackManagerCredentialSummary } from './types';
|
||||
|
||||
/** True when n8n can use this Slack manager credential to create and install Slack apps. */
|
||||
export function isSlackManagerCredentialReady(
|
||||
credential: Pick<SlackManagerCredentialSummary, 'connected' | 'reconnectRequired'>,
|
||||
): boolean {
|
||||
return credential.connected && !credential.reconnectRequired;
|
||||
}
|
||||
|
|
@ -383,6 +383,7 @@ export interface AgentBuilderOpenSuspension {
|
|||
|
||||
export interface AgentChatQueueItem {
|
||||
id: string;
|
||||
steeringExecutionId: string | null;
|
||||
message: string;
|
||||
attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number }>;
|
||||
createdAt: string;
|
||||
|
|
@ -390,6 +391,7 @@ export interface AgentChatQueueItem {
|
|||
|
||||
export interface AgentChatQueueResponse {
|
||||
items: AgentChatQueueItem[];
|
||||
steerableExecutionId: string | null;
|
||||
}
|
||||
|
||||
/** Chat history envelope returned by the agent chat messages endpoints. */
|
||||
|
|
|
|||
|
|
@ -588,6 +588,10 @@ export {
|
|||
type OtlpProtocol,
|
||||
} from './otel/update-otel-settings.dto';
|
||||
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
|
||||
export {
|
||||
GetOtelSettingsQueryPublicDto,
|
||||
OtelSettingsPublicDto,
|
||||
} from './otel/otel-settings-public.dto';
|
||||
|
||||
export {
|
||||
PromotionChangesDto,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { InstanceAiThreadTabsRequestDto } from '../instance-ai-thread-tabs-request.dto';
|
||||
|
||||
describe('InstanceAiThreadTabsRequestDto', () => {
|
||||
const state = {
|
||||
tabs: [{ type: 'workflow' as const, id: 'wf-1', name: 'Digest' }],
|
||||
closedTabs: [],
|
||||
activeTab: { type: 'workflow' as const, id: 'wf-1' },
|
||||
};
|
||||
|
||||
// The save endpoint stores the parsed payload as it is, so it must hold only schema keys.
|
||||
it('strips unknown keys, also inside the tabs', () => {
|
||||
const result = InstanceAiThreadTabsRequestDto.safeParse({
|
||||
...state,
|
||||
unknownKey: 'dropped',
|
||||
tabs: [{ ...state.tabs[0], unknownKey: 'dropped' }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(state);
|
||||
});
|
||||
|
||||
it('keeps previewOpen when it is sent, and leaves it out when it is not', () => {
|
||||
expect(InstanceAiThreadTabsRequestDto.safeParse({ ...state, previewOpen: false }).data).toEqual(
|
||||
{
|
||||
...state,
|
||||
previewOpen: false,
|
||||
},
|
||||
);
|
||||
expect(InstanceAiThreadTabsRequestDto.safeParse(state).data).not.toHaveProperty('previewOpen');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import '../../openapi-extend';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
otelSettingsPublicDescription,
|
||||
otelSettingsPublicFieldDocs as docs,
|
||||
} from './otel-settings-public.openapi';
|
||||
import { OTLP_PROTOCOLS } from './update-otel-settings.dto';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class OtelSettingsPublicDto extends Z.class(
|
||||
{
|
||||
enabled: z.boolean().openapi(docs.enabled),
|
||||
exporterProtocol: z.string().openapi({ ...docs.exporterProtocol, enum: [...OTLP_PROTOCOLS] }),
|
||||
exporterEndpoint: z.string().openapi(docs.exporterEndpoint),
|
||||
exporterTracingPath: z.string().openapi(docs.exporterTracingPath),
|
||||
exporterServiceName: z.string().openapi(docs.exporterServiceName),
|
||||
exporterHeaders: z.string().openapi(docs.exporterHeaders),
|
||||
tracesSampleRate: z.number().openapi(docs.tracesSampleRate),
|
||||
startupConnectivityTimeoutMs: z.number().openapi(docs.startupConnectivityTimeoutMs),
|
||||
includeNodeSpans: z.boolean().openapi(docs.includeNodeSpans),
|
||||
injectOutbound: z.boolean().openapi(docs.injectOutbound),
|
||||
productionExecutionsOnly: z.boolean().openapi(docs.productionExecutionsOnly),
|
||||
},
|
||||
{ strict: true },
|
||||
) {
|
||||
static schema = super.schema.openapi({ description: otelSettingsPublicDescription });
|
||||
}
|
||||
|
||||
export class GetOtelSettingsQueryPublicDto extends Z.class({}, { strict: true }) {}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
|
||||
|
||||
export const otelSettingsPublicDescription =
|
||||
'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a ' +
|
||||
'full replacement: every field must be provided, except `exporterProtocol`, which defaults to ' +
|
||||
'`http/protobuf` when omitted. Fields managed declaratively via environment variables are ' +
|
||||
'returned with their effective value. A write can repeat these values, but changing them returns 409.';
|
||||
|
||||
export const otelSettingsPublicFieldDocs = {
|
||||
enabled: { description: 'Whether OpenTelemetry tracing is enabled.', example: true },
|
||||
exporterProtocol: {
|
||||
default: 'http/protobuf',
|
||||
description:
|
||||
'The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port ' +
|
||||
'4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls ' +
|
||||
'TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an ' +
|
||||
'instance that sets this field to another value with an environment variable rejects that ' +
|
||||
'write with 409.',
|
||||
example: 'http/protobuf',
|
||||
},
|
||||
exporterEndpoint: {
|
||||
format: 'uri',
|
||||
description:
|
||||
'The base URL of the OTLP collector to export traces to. The value must be an `http://` or ' +
|
||||
'`https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and ' +
|
||||
'`http://` is plaintext.',
|
||||
example: 'http://localhost:4318',
|
||||
},
|
||||
exporterTracingPath: {
|
||||
description:
|
||||
'The path appended to the endpoint for the OTLP traces signal. Ignored when ' +
|
||||
'`exporterProtocol` is `grpc`, which takes no URL path.',
|
||||
example: '/v1/traces',
|
||||
},
|
||||
exporterServiceName: {
|
||||
minLength: 1,
|
||||
description: 'The `service.name` resource attribute reported on every span.',
|
||||
example: 'n8n',
|
||||
},
|
||||
exporterHeaders: {
|
||||
description:
|
||||
'Additional headers sent to the OTLP collector, as a single string of comma-separated ' +
|
||||
'`key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace ' +
|
||||
'around each key and value is trimmed; a value may contain spaces but not commas. Use an ' +
|
||||
'empty string when unused.',
|
||||
example: 'authorization=Bearer my-token,x-tenant-id=acme',
|
||||
},
|
||||
tracesSampleRate: {
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'The ratio of traces to sample, between 0 (none) and 1 (all).',
|
||||
example: 1,
|
||||
},
|
||||
startupConnectivityTimeoutMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description:
|
||||
'How long, in milliseconds, to wait when checking the collector is reachable. Also used as ' +
|
||||
'the timeout for the test-trace endpoint.',
|
||||
example: 2000,
|
||||
},
|
||||
includeNodeSpans: {
|
||||
description: 'Whether to emit a span for each node execution in addition to the workflow span.',
|
||||
example: true,
|
||||
},
|
||||
injectOutbound: {
|
||||
description:
|
||||
'Whether to inject trace context headers into outbound HTTP requests made by nodes.',
|
||||
example: true,
|
||||
},
|
||||
productionExecutionsOnly: {
|
||||
description:
|
||||
'When true, only production executions of published (active) workflows are traced, not ' +
|
||||
'manual/test runs.',
|
||||
example: true,
|
||||
},
|
||||
} as const satisfies Record<string, ZodOpenAPIMetadata>;
|
||||
|
|
@ -320,7 +320,7 @@ export {
|
|||
export type { UsageState } from './schemas/usage.schema';
|
||||
|
||||
export type {
|
||||
BreakingChangeRuleSeverity,
|
||||
BreakingChangeRuleImpact,
|
||||
BreakingChangeRecommendation,
|
||||
BreakingChangeAffectedWorkflow,
|
||||
BreakingChangeInstanceIssue,
|
||||
|
|
@ -708,6 +708,13 @@ export type {
|
|||
InstanceAiEvalSeedFolder,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
export {
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from './schemas/instance-ai-setup-credential-selection';
|
||||
|
||||
export type {
|
||||
McpRegistryServerStatus,
|
||||
McpRegistryServerIconResponse,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
instanceAiSetupCredentialAppliedKey,
|
||||
instanceAiSetupCredentialSelectionKey,
|
||||
readPendingInstanceAiSetupCredentialSelections,
|
||||
type InstanceAiSetupCredentialSelection,
|
||||
} from '../instance-ai-setup-credential-selection';
|
||||
|
||||
const selection: InstanceAiSetupCredentialSelection = {
|
||||
selectionId: 'choice-1',
|
||||
credentialType: 'slackApi',
|
||||
credentialId: 'credential-1',
|
||||
};
|
||||
const itemId = 'workflow-1:credential:slackApi';
|
||||
|
||||
describe('readPendingInstanceAiSetupCredentialSelections', () => {
|
||||
it('reads only valid selections for the requested workflow', () => {
|
||||
expect(
|
||||
readPendingInstanceAiSetupCredentialSelections(
|
||||
{
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey('workflow-2:credential:slackApi')]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey('workflow-1:credential:gmailOAuth2')]: selection,
|
||||
[instanceAiSetupCredentialSelectionKey(`${itemId}:invalid`)]: { credentialId: 'id' },
|
||||
},
|
||||
'workflow-1',
|
||||
),
|
||||
).toEqual([{ itemId, selection }]);
|
||||
});
|
||||
|
||||
it('keeps a newer choice pending when an earlier save finishes', () => {
|
||||
const metadata = {
|
||||
[instanceAiSetupCredentialSelectionKey(itemId)]: selection,
|
||||
[instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')]: true,
|
||||
};
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([
|
||||
{ itemId, selection },
|
||||
]);
|
||||
metadata[instanceAiSetupCredentialAppliedKey(itemId, selection.selectionId)] = true;
|
||||
metadata[instanceAiSetupCredentialAppliedKey(itemId, 'older-choice')] = true;
|
||||
expect(readPendingInstanceAiSetupCredentialSelections(metadata, 'workflow-1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,22 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Enums
|
||||
export const breakingChangeRuleSeveritySchema = z.enum(['low', 'medium', 'critical']);
|
||||
export type BreakingChangeRuleSeverity = z.infer<typeof breakingChangeRuleSeveritySchema>;
|
||||
|
||||
/**
|
||||
* What happens if the user does not fix a breaking change before the update.
|
||||
*
|
||||
* - `upgradeBlocked`: the instance does not start, or the update cannot proceed.
|
||||
* - `executionsFail`: affected executions error.
|
||||
* - `behaviorChanges`: executions keep running, but with a different result.
|
||||
* - `capabilityRemoved`: no runtime impact, a removed capability is no longer available.
|
||||
*/
|
||||
export const breakingChangeRuleImpactSchema = z.enum([
|
||||
'executionsFail',
|
||||
'behaviorChanges',
|
||||
'capabilityRemoved',
|
||||
'upgradeBlocked',
|
||||
]);
|
||||
export type BreakingChangeRuleImpact = z.infer<typeof breakingChangeRuleImpactSchema>;
|
||||
|
||||
export const breakingChangeIssueLevelSchema = z.enum(['info', 'warning', 'error']);
|
||||
|
||||
|
|
@ -74,7 +88,7 @@ const ruleResultBaseSchema = z.object({
|
|||
ruleId: z.string(),
|
||||
ruleTitle: z.string(),
|
||||
ruleDescription: z.string(),
|
||||
ruleSeverity: breakingChangeRuleSeveritySchema,
|
||||
ruleImpact: breakingChangeRuleImpactSchema,
|
||||
ruleDocumentationUrl: z.string().optional(),
|
||||
recommendations: z.array(recommendationSchema),
|
||||
// True when an automated migration is registered for this rule, so the UI
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const instanceAiSetupCredentialSelectionSchema = z.object({
|
||||
selectionId: z.string().min(1),
|
||||
credentialType: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
nodeNames: z.array(z.string().min(1)).min(1).optional(),
|
||||
});
|
||||
|
||||
export type InstanceAiSetupCredentialSelection = z.infer<
|
||||
typeof instanceAiSetupCredentialSelectionSchema
|
||||
>;
|
||||
|
||||
const SELECTION_KEY_PREFIX = 'instanceAiSetupCredentialSelection:';
|
||||
|
||||
export function instanceAiSetupCredentialSelectionKey(itemId: string): string {
|
||||
return `${SELECTION_KEY_PREFIX}${itemId}`;
|
||||
}
|
||||
|
||||
/** A marker for each choice keeps reordered saves independent. */
|
||||
export function instanceAiSetupCredentialAppliedKey(itemId: string, selectionId: string): string {
|
||||
return `instanceAiSetupCredentialApplied:${itemId}:${selectionId}`;
|
||||
}
|
||||
|
||||
export function readPendingInstanceAiSetupCredentialSelections(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
workflowId: string,
|
||||
): Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> {
|
||||
const pending: Array<{ itemId: string; selection: InstanceAiSetupCredentialSelection }> = [];
|
||||
const workflowPrefix = `${workflowId}:credential:`;
|
||||
for (const [key, value] of Object.entries(metadata ?? {})) {
|
||||
if (!key.startsWith(`${SELECTION_KEY_PREFIX}${workflowPrefix}`)) continue;
|
||||
const parsed = instanceAiSetupCredentialSelectionSchema.safeParse(value);
|
||||
if (!parsed.success) continue;
|
||||
const itemId = key.slice(SELECTION_KEY_PREFIX.length);
|
||||
const typePrefix = `${workflowPrefix}${parsed.data.credentialType}`;
|
||||
if (itemId !== typePrefix && !itemId.startsWith(`${typePrefix}:`)) continue;
|
||||
if (metadata?.[instanceAiSetupCredentialAppliedKey(itemId, parsed.data.selectionId)] === true) {
|
||||
continue;
|
||||
}
|
||||
pending.push({ itemId, selection: parsed.data });
|
||||
}
|
||||
// Apply node-specific choices after choices for the whole service.
|
||||
return pending.sort(
|
||||
(a, b) =>
|
||||
Number(a.selection.nodeNames !== undefined) - Number(b.selection.nodeNames !== undefined),
|
||||
);
|
||||
}
|
||||
|
|
@ -1174,6 +1174,7 @@ export const setupItemSchema = z.discriminatedUnion('kind', [
|
|||
kind: z.literal('credential'),
|
||||
credentialType: z.string(),
|
||||
appDisplayName: z.string().optional(),
|
||||
preferNew: z.boolean().optional(),
|
||||
nodeBindings: z.array(z.object({ nodeName: z.string() })).optional(),
|
||||
setupHint: credentialSetupHintSchema.optional(),
|
||||
/** Why the app is needed, e.g. "for the docs search". */
|
||||
|
|
@ -1758,6 +1759,7 @@ export class InstanceAiCorrectTaskRequest extends Z.class({
|
|||
* - `canvas_action_button` — Instance AI button on the workflow canvas
|
||||
* - `canvas_choice_prompt` — empty-canvas choice prompt that opens Instance AI
|
||||
* - `node_error_view` — "Ask AI" from a node error / failed-execution view
|
||||
* - `workflow_error_nudge` — nudge on a workflow error toast that opens the Assistant to fix the run
|
||||
* - `credential_edit` — credential setup help from the credential edit modal
|
||||
* - `credentials_list` — credential setup help from the credentials list
|
||||
* - `agent_builder_page` — Instance AI hand-off from the agent builder
|
||||
|
|
@ -1776,6 +1778,7 @@ export const INSTANCE_AI_THREAD_SOURCES = [
|
|||
'canvas_action_button',
|
||||
'canvas_choice_prompt',
|
||||
'node_error_view',
|
||||
'workflow_error_nudge', // Experiment cleanup (119_surface_assistant_on_workflow_error)
|
||||
'credential_edit',
|
||||
'credentials_list',
|
||||
'agent_builder_page',
|
||||
|
|
@ -2877,6 +2880,9 @@ export class InstanceAiEvalExecutionRequest extends Z.class({
|
|||
* budget can exceed the 15 minutes a plain run takes.
|
||||
*/
|
||||
timeoutMs: z.number().int().min(30_000).max(3_600_000).optional(),
|
||||
/** Data tables the caller reseeded with this scenario's rows. A Data Table read
|
||||
* bound to one of them reads the table instead of pinned rows. */
|
||||
seededDataTableIds: z.array(z.string().min(1)).max(20).optional(),
|
||||
}) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.41.0",
|
||||
"version": "1.42.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ describe('eligibleModules', () => {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'inbound-auth-core',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -126,6 +127,7 @@ describe('eligibleModules', () => {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'inbound-auth-core',
|
||||
'type-availability-policies',
|
||||
]);
|
||||
});
|
||||
|
|
@ -253,6 +255,26 @@ describe('initModules', () => {
|
|||
expect(ModuleClass.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should init only the listed modules when given a list', async () => {
|
||||
const ListedModule = { init: vi.fn() };
|
||||
const OtherModule = { init: vi.fn() };
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
getEntries: vi.fn().mockReturnValue([
|
||||
['insights', { class: ListedModule }],
|
||||
['mcp', { class: OtherModule }],
|
||||
]),
|
||||
});
|
||||
Container.get = vi.fn().mockImplementation((moduleClass: unknown) => moduleClass);
|
||||
|
||||
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||
|
||||
await moduleRegistry.initModules('main', ['insights']);
|
||||
|
||||
expect(ListedModule.init).toHaveBeenCalled();
|
||||
expect(OtherModule.init).not.toHaveBeenCalled();
|
||||
expect(moduleRegistry.getActiveModules()).toEqual(['insights']);
|
||||
});
|
||||
|
||||
it('should accept module without `init` method', async () => {
|
||||
const ModuleClass = {};
|
||||
const moduleMetadata = mock<ModuleMetadata>({
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export class ModuleRegistry {
|
|||
'workflow-reviews',
|
||||
'instance-ai',
|
||||
'agents',
|
||||
'inbound-auth-core',
|
||||
];
|
||||
|
||||
private readonly activeModules: string[] = [];
|
||||
|
|
@ -159,9 +160,15 @@ export class ModuleRegistry {
|
|||
* specific setup.
|
||||
*
|
||||
* `ModuleRegistry.loadModules` must have been called before.
|
||||
*
|
||||
* @param only Init only these modules, for a one-off command that needs a few of them.
|
||||
*/
|
||||
async initModules(instanceType: InstanceType) {
|
||||
async initModules(instanceType: InstanceType, only?: ModuleName[]) {
|
||||
const selected = only ? new Set<string>(only) : undefined;
|
||||
|
||||
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
|
||||
if (selected && !selected.has(moduleName)) continue;
|
||||
|
||||
const { licenseFlag, instanceTypes, class: ModuleClass } = moduleEntry;
|
||||
|
||||
if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const MODULE_NAMES = [
|
|||
'policy-infrastructure',
|
||||
'type-availability-policies',
|
||||
'instance-reporting',
|
||||
'inbound-auth-core',
|
||||
] as const;
|
||||
|
||||
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -27,9 +27,34 @@ and infrastructure services that need the persistence layer or that only
|
|||
|
||||
## What lives here
|
||||
|
||||
The package contains `UrlService`. The next PRs move the response errors,
|
||||
`CacheService`, `RedisClientService`, `ProtectedResourceRegistry`, `EventService`,
|
||||
`RoleService`, the finder services and the scope checks here, one area at a time.
|
||||
- `EventService`: the shared, typed event bus.
|
||||
- `UrlService`: instance and webhook URLs.
|
||||
- `CacheService`: application cache access.
|
||||
- `RedisClientService`: shared Redis connections and support code.
|
||||
|
||||
The next PRs move `ProtectedResourceRegistry`, `RoleService`, the finder services
|
||||
and the scope checks here, one area at a time.
|
||||
|
||||
### Register event payloads
|
||||
|
||||
`EventMap` is an open interface. Each event owner augments it with its payload
|
||||
types. This keeps domain dependencies out of the shared event bus.
|
||||
|
||||
```typescript
|
||||
import type {} from '@n8n/backend-services';
|
||||
|
||||
declare module '@n8n/backend-services' {
|
||||
interface EventMap {
|
||||
'example-completed': { id: string };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Include the augmentation file in the owner's TypeScript project. If another
|
||||
package uses these events, include the augmentation in the owner's exported
|
||||
types. The CLI registers its existing maps in `src/events/event-map.ts`.
|
||||
Augmentation needs no runtime registration. All consumers import `EventService`
|
||||
from `@n8n/backend-services` and use the same DI token.
|
||||
|
||||
## Compatibility with cli
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ export default defineConfig(
|
|||
{
|
||||
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
|
||||
rules: {
|
||||
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
|
||||
'n8n-local-rules/no-type-unsafe-event-emitter': 'off',
|
||||
// `vi.importActual<typeof import('x')>('x')` needs inline import types.
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,15 +30,4 @@ export default defineConfig({
|
|||
],
|
||||
'n8n-local-rules/no-type-unsafe-event-emitter': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'],
|
||||
// An override that names a jsPlugin rule must re-declare the plugin.
|
||||
jsPlugins: ['@n8n/eslint-config/plugin'],
|
||||
rules: {
|
||||
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
|
||||
'n8n-local-rules/no-type-unsafe-event-emitter': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-services",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
@ -23,15 +23,27 @@
|
|||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@n8n/backend-common": "workspace:*",
|
||||
"@n8n/config": "workspace:*",
|
||||
"@n8n/di": "workspace:*"
|
||||
"@n8n/constants": "workspace:*",
|
||||
"@n8n/decorators": "workspace:*",
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/errors": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"cache-manager": "catalog:",
|
||||
"ioredis": "5.3.2",
|
||||
"n8n-workflow": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-config": "workspace:*",
|
||||
"@n8n/oxlint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@types/lodash": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"ioredis-mock": "^8.8.1",
|
||||
"lodash": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
"typescript": "catalog:typescript",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { UnexpectedError } from '@n8n/errors';
|
||||
|
||||
export class UncacheableValueError extends UnexpectedError {
|
||||
constructor(key: string) {
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Container, Service } from '@n8n/di';
|
||||
import { expectTypeOf } from 'vitest';
|
||||
|
||||
import { EventService, type EventMap } from '../../index';
|
||||
|
||||
declare module '../../index' {
|
||||
interface EventMap {
|
||||
'test-event': { id: string };
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class EventConsumer {
|
||||
constructor(readonly events: EventService) {}
|
||||
}
|
||||
|
||||
describe('EventService', () => {
|
||||
afterEach(() => {
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
it('shares the event bus with injected consumers', () => {
|
||||
const events = Container.get(EventService);
|
||||
const consumer = Container.get(EventConsumer);
|
||||
const listener = vi.fn();
|
||||
consumer.events.on('test-event', listener);
|
||||
|
||||
events.emit('test-event', { id: 'first' });
|
||||
consumer.events.off('test-event', listener);
|
||||
events.emit('test-event', { id: 'second' });
|
||||
|
||||
expect(consumer.events).toBe(events);
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith({ id: 'first' });
|
||||
});
|
||||
|
||||
it('uses augmented event names and payload types', () => {
|
||||
const events = new EventService();
|
||||
expectTypeOf<EventMap['test-event']>().toEqualTypeOf<{ id: string }>();
|
||||
expectTypeOf(events.emit<'test-event'>).parameters.toEqualTypeOf<
|
||||
[eventName: 'test-event', payload?: { id: string }]
|
||||
>();
|
||||
expectTypeOf(events.on<'test-event'>)
|
||||
.parameter(1)
|
||||
.toEqualTypeOf<(payload: { id: string }) => void>();
|
||||
expectTypeOf(events.once<'test-event'>)
|
||||
.parameter(1)
|
||||
.toEqualTypeOf<(payload: { id: string }) => void>();
|
||||
expectTypeOf(events.off<'test-event'>)
|
||||
.parameter(1)
|
||||
.toEqualTypeOf<(payload: { id: string }) => void>();
|
||||
// @ts-expect-error Only registered event names are valid.
|
||||
expectTypeOf(events.emit<'unregistered-event'>);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { TypedEmitter } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
/** Each event owner adds its payload types through module augmentation. */
|
||||
export interface EventMap {}
|
||||
|
||||
@Service()
|
||||
export class EventService extends TypedEmitter<EventMap> {}
|
||||
|
|
@ -1 +1,6 @@
|
|||
export { EventService, type EventMap } from './events/event.service';
|
||||
export { UncacheableValueError } from './errors/cache-errors/uncacheable-value.error';
|
||||
export { CacheService } from './services/cache/cache.service';
|
||||
export { RedisClientService } from './services/redis-client.service';
|
||||
export type { RedisClientType } from './services/redis.types';
|
||||
export { UrlService } from './services/url.service';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import Redis from 'ioredis';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { RedisClientService } from '@/services/redis-client.service';
|
||||
import { RedisClientService } from '../redis-client.service';
|
||||
|
||||
type EventHandler = (...args: unknown[]) => void;
|
||||
|
||||
|
|
@ -27,10 +27,10 @@ vi.mock('ioredis', () => {
|
|||
});
|
||||
|
||||
describe('RedisClientService', () => {
|
||||
const logger = mockInstance(Logger);
|
||||
const logger = mock<Logger>();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
const globalConfig = mockInstance(GlobalConfig, {
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
queue: {
|
||||
bull: {
|
||||
redis: {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { CacheService } from '../cache.service';
|
||||
|
||||
const cacheService = Container.get(CacheService);
|
||||
const store = mock<NonNullable<CacheService['cache']>['store']>({ isCacheable: () => true });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user