perf(core): Send agent builder config writes as objects with changed fields only (#40666)

This commit is contained in:
Riqwan Thamir 2026-10-08 12:55:54 +00:00 committed by GitHub
parent 983f5ecea8
commit 2aa3fbf185
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 70 deletions

View File

@ -20,6 +20,7 @@ import type { User } from '@n8n/db';
import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test';
import { NodeConnectionTypes, UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { ZodTypeAny } from 'zod';
import type { CredentialTypes } from '@/credential-types';
import { ConflictError, LockedError } from '@n8n/errors';
@ -368,7 +369,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -395,7 +396,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -409,6 +410,109 @@ describe('AgentsBuilderToolsService', () => {
});
});
it('write_config keeps stored top-level fields that the model omits', async () => {
const { service, agentsService } = makeService();
const storedConfig: AgentJsonConfig = {
...baseConfig,
memory: { enabled: true, storage: 'n8n' },
config: { webSearch: { enabled: false }, promptCaching: { enabled: true } },
};
const currentConfig = { ...storedConfig, integrations: [] };
agentsService.findById.mockResolvedValue(makeAgent(storedConfig));
agentsService.updateConfig.mockImplementation(async (_agentId, _projectId, config) => ({
config: config as AgentJsonConfig,
configHash: 'config-hash',
updatedAt: '2026-01-02T00:00:00.000Z',
versionId: 'v2',
}));
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
config: { name: 'Support Agent', instructions: 'Help with support tickets.' },
},
ctx,
);
const saved = agentsService.updateConfig.mock.calls[0][2] as AgentJsonConfig;
expect(saved).toEqual(
expect.objectContaining({
name: 'Support Agent',
instructions: 'Help with support tickets.',
model: baseConfig.model,
credential: baseConfig.credential,
memory: { enabled: true, storage: 'n8n' },
}),
);
// An omitted `config` must not turn a disabled web search back on.
expect(saved.config?.webSearch).toEqual({ enabled: false });
expect(result).toEqual({ ok: true, configMutated: true, agentId, configHash: 'config-hash' });
});
it('write_config still accepts a config sent as a JSON string', async () => {
const { service, agentsService } = makeService();
const currentConfig = { ...baseConfig, integrations: [] };
const updatedConfig = { ...currentConfig, instructions: 'Help with support tickets.' };
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
agentsService.updateConfig.mockResolvedValue({
config: updatedConfig,
configHash: 'config-hash',
updatedAt: '2026-01-02T00:00:00.000Z',
versionId: 'v2',
});
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
config: JSON.stringify(updatedConfig),
},
ctx,
);
expect(result).toEqual({ ok: true, configMutated: true, agentId, configHash: 'config-hash' });
});
it('write_config and patch_config input schemas take objects and parse stringified values', () => {
const { service } = makeService();
const writeSchema = getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG)
.inputSchema as ZodTypeAny;
const patchSchema = getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG)
.inputSchema as ZodTypeAny;
const ops = [{ op: 'replace', path: '/instructions', value: 'Changed.' }];
expect(writeSchema.parse({ config: { name: 'A' }, baseConfigHash: null })).toEqual({
config: { name: 'A' },
baseConfigHash: null,
});
expect(
writeSchema.parse({ config: JSON.stringify({ name: 'A' }), baseConfigHash: null }),
).toEqual({ config: { name: 'A' }, baseConfigHash: null });
expect(patchSchema.parse({ operations: ops, baseConfigHash: 'h' }).operations).toEqual(ops);
expect(
patchSchema.parse({ operations: JSON.stringify(ops), baseConfigHash: 'h' }).operations,
).toEqual(ops);
expect(patchSchema.safeParse({ operations: '[not json', baseConfigHash: 'h' }).success).toBe(
false,
);
});
it('write_config input schema maps the legacy json key to config', () => {
const { service } = makeService();
const tool = getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG);
const writeSchema = tool.inputSchema as ZodTypeAny;
expect(
writeSchema.parse({ json: JSON.stringify({ name: 'A' }), baseConfigHash: 'h' }),
).toEqual({ config: { name: 'A' }, baseConfigHash: 'h' });
// The model only sees the new key.
expect(zodToJsonSchema(writeSchema)).toEqual(
expect.objectContaining({
type: 'object',
required: ['config', 'baseConfigHash'],
}),
);
});
it('write_config failure result is not stamped with configMutated', async () => {
const { service, agentsService } = makeService();
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
@ -416,7 +520,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: 'stale-hash',
json: JSON.stringify(baseConfig),
config: baseConfig,
},
ctx,
);
@ -433,7 +537,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(baseConfig),
json: JSON.stringify({ ...baseConfig, instructions: 'Changed.' }),
config: { ...baseConfig, instructions: 'Changed.' },
},
ctx,
);
@ -453,7 +557,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(baseConfig),
operations: JSON.stringify([{ op: 'replace', path: '/instructions', value: 'Changed.' }]),
operations: [{ op: 'replace', path: '/instructions', value: 'Changed.' }],
},
ctx,
);
@ -483,9 +587,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Updated instructions' }],
},
ctx,
);
@ -523,10 +625,10 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
operations: [
{ op: 'remove', path: '/integrations' },
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
],
},
ctx,
);
@ -553,7 +655,7 @@ describe('AgentsBuilderToolsService', () => {
await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash({ ...baseConfig, integrations: [] }),
json: JSON.stringify({ ...baseConfig, instructions: 'Changed.' }),
config: { ...baseConfig, instructions: 'Changed.' },
},
ctx,
);
@ -581,9 +683,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Updated instructions' }],
},
ctx,
);
@ -610,9 +710,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: 'stale-hash',
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Updated instructions' }],
},
ctx,
);
@ -660,7 +758,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([{ op: 'remove', path: '/integrations/1' }]),
operations: [{ op: 'remove', path: '/integrations/1' }],
},
ctx,
);
@ -704,9 +802,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Updated instructions' }],
},
ctx,
);
@ -748,7 +844,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -784,7 +880,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -815,7 +911,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -842,9 +938,9 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
operations: [
{ op: 'replace', path: '/tools', value: [makeLinearToolWithFromAiTeamId()] },
]),
],
},
ctx,
);
@ -922,7 +1018,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -975,7 +1071,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([{ op: 'replace', path: '/tools', value: [nodeTool] }]),
operations: [{ op: 'replace', path: '/tools', value: [nodeTool] }],
},
ctx,
);
@ -1022,7 +1118,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1069,9 +1165,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Updated instructions' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Updated instructions' }],
},
ctx,
);
@ -1111,7 +1205,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1142,7 +1236,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1178,7 +1272,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentDraftConfig),
json: JSON.stringify(draftConfig),
config: draftConfig,
},
ctx,
);
@ -1208,7 +1302,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentDraftConfig),
json: JSON.stringify(draftConfig),
config: draftConfig,
},
ctx,
);
@ -1229,7 +1323,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(draftConfig),
config: draftConfig,
},
ctx,
);
@ -1250,7 +1344,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(malformedConfig),
config: malformedConfig,
},
ctx,
);
@ -1278,9 +1372,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([
{ op: 'replace', path: '/instructions', value: 'Triage Slack messages.' },
]),
operations: [{ op: 'replace', path: '/instructions', value: 'Triage Slack messages.' }],
},
ctx,
);
@ -1306,7 +1398,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
operations: JSON.stringify([{ op: 'replace', path: '/model', value: '' }]),
operations: [{ op: 'replace', path: '/model', value: '' }],
},
ctx,
);
@ -1328,7 +1420,7 @@ describe('AgentsBuilderToolsService', () => {
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: 'stale-hash',
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1368,7 +1460,7 @@ describe('AgentsBuilderToolsService', () => {
await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1410,7 +1502,7 @@ describe('AgentsBuilderToolsService', () => {
await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1460,7 +1552,7 @@ describe('AgentsBuilderToolsService', () => {
await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);
@ -1512,7 +1604,7 @@ describe('AgentsBuilderToolsService', () => {
await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(updatedConfig),
config: updatedConfig,
},
ctx,
);

View File

@ -294,9 +294,10 @@ export const FEW_SHOT_FLOWS_SECTION = `\
credential are already set (system auto-selected default), keep them and
mention the choice as changeable; otherwise \`resolve_llm({})\` once,
silently; if it reports missing credentials, mark the model task \`blocked\`.
2. \`write_config(...)\` with the \`configHash\` from step 1, the instructions,
and the resolved model and credential — or \`model: ""\` and no
\`credential\` while the model task is blocked.
2. \`write_config(...)\` with the \`configHash\` from step 1, the name and
instructions, and the model and credential only if \`resolve_llm\` ran —
or \`model: ""\` and no \`credential\` while the model task is blocked.
Omit fields that are already stored and unchanged.
3. Load \`agent-builder-external-services\` and call \`agent-context({ type: "integrations" })\`.
4. \`patch_config(...)\` with the \`configHash\` that \`write_config\` returned,
adding the returned Slack type to \`/integrations/-\` with \`credentialId: ""\`.

View File

@ -120,6 +120,54 @@ const CONFIG_WRITE_RESULT_RULE =
'the same baseConfigHash. On stage: "stale", the result carries the current config and configHash: ' +
're-apply your change to it and retry once. ';
/**
* Models sometimes still send a structured argument as a JSON string. Parse it
* so the schema sees the value, and keep it unchanged when it is not valid JSON.
*/
function parseJsonStringInput(value: unknown): unknown {
if (typeof value !== 'string') return value;
const parsed = tryParseConfigJson(value);
return parsed.ok ? parsed.data : value;
}
/**
* `write_config` arguments are objects so the model does not have to escape
* every quote. A JSON string is still accepted, as older tool calls sent one.
*/
function readStructuredToolInput(
value: unknown,
): { ok: true; data: unknown } | { ok: false; errors: ConfigValidationError[] } {
return typeof value === 'string' ? tryParseConfigJson(value) : { ok: true, data: value };
}
/**
* `write_config` keeps every top-level field the model omits. Merge the stored
* value before validation, so builder defaults (web search, prompt caching)
* see the real stored state instead of an empty field.
*/
function mergeOmittedTopLevelFields(submitted: unknown, stored: AgentJsonConfig | null): unknown {
if (!stored || !isRecord(submitted)) return submitted;
return { ...stored, ...submitted };
}
/**
* Before `config`, `write_config` took the config as a `json` string. Map that
* key so a checkpoint resumed after a deploy can still run a pending call.
* The model only sees the `config` key in the tool schema.
*/
function renameLegacyWriteConfigKey(value: unknown): unknown {
if (!isRecord(value) || value.config !== undefined || value.json === undefined) return value;
const { json, ...rest } = value;
return { ...rest, config: json };
}
const jsonPatchOperationSchema = z.object({
op: z.enum(['add', 'remove', 'replace', 'move', 'copy', 'test']),
path: z.string().describe('JSON Pointer, e.g. "/instructions" or "/tools/-"'),
from: z.string().optional().describe('Source JSON Pointer for move and copy'),
value: z.unknown().optional().describe('New value for add, replace, and test'),
});
const AGENT_LOCKED_BY_EDITOR_ERROR: ConfigValidationError = {
path: '(root)',
message:
@ -874,7 +922,7 @@ export class AgentsBuilderToolsService {
return new Tool(BUILDER_TOOLS.PATCH_CONFIG)
.description(
'Apply RFC 6902 JSON Patch operations to the current agent configuration. ' +
'Pass an array of patch operations as a JSON string. ' +
'Pass the patch operations as an array of objects, not as a JSON string. ' +
BASE_CONFIG_HASH_RULE +
'Supported ops: add, remove, replace, move, copy, test. ' +
CONFIG_WRITE_RESULT_RULE +
@ -883,7 +931,9 @@ export class AgentsBuilderToolsService {
)
.input(
z.object({
operations: z.string().describe('RFC 6902 JSON Patch operations array as a JSON string'),
operations: z
.preprocess(parseJsonStringInput, z.array(jsonPatchOperationSchema))
.describe('RFC 6902 JSON Patch operations'),
baseConfigHash: z.string().nullable().describe(BASE_CONFIG_HASH_FIELD_DESCRIPTION),
}),
)
@ -891,7 +941,7 @@ export class AgentsBuilderToolsService {
async ({
operations,
baseConfigHash,
}: { operations: string; baseConfigHash: string | null }) =>
}: { operations: unknown; baseConfigHash: string | null }) =>
await this.patchBuilderConfig(agentId, projectId, user, operations, baseConfigHash),
)
.build();
@ -900,21 +950,28 @@ export class AgentsBuilderToolsService {
private buildWriteConfigTool(agentId: string, projectId: string, user: User) {
return new Tool(BUILDER_TOOLS.WRITE_CONFIG)
.description(
'Create or replace the agent configuration by writing a complete JSON string. ' +
'Optional fields you omit keep their stored value. ' +
'Create or update the agent configuration. Pass the config as an object, not as a JSON string. ' +
'Send only the top-level fields you set or change: every top-level field you omit ' +
'(including model and credential) keeps its stored value, and a field you send replaces ' +
'the stored field whole. A fresh agent with no stored config needs name, model, and instructions. ' +
BASE_CONFIG_HASH_RULE +
CONFIG_WRITE_RESULT_RULE +
'Failure errors carry path, message, expected, and received fields.',
)
.input(
z.object({
json: z.string().describe('Complete agent configuration as a JSON string'),
baseConfigHash: z.string().nullable().describe(BASE_CONFIG_HASH_FIELD_DESCRIPTION),
}),
z.preprocess(
renameLegacyWriteConfigKey,
z.object({
config: z
.preprocess(parseJsonStringInput, z.record(z.unknown()))
.describe('Agent configuration object with the top-level fields to set'),
baseConfigHash: z.string().nullable().describe(BASE_CONFIG_HASH_FIELD_DESCRIPTION),
}),
),
)
.handler(
async ({ json, baseConfigHash }: { json: string; baseConfigHash: string | null }) =>
await this.writeBuilderConfig(agentId, projectId, user, json, baseConfigHash),
async ({ config, baseConfigHash }: { config: unknown; baseConfigHash: string | null }) =>
await this.writeBuilderConfig(agentId, projectId, user, config, baseConfigHash),
)
.build();
}
@ -1388,24 +1445,26 @@ export class AgentsBuilderToolsService {
agentId: string,
projectId: string,
user: User,
json: string,
input: unknown,
baseConfigHash: string | null,
) {
const editorLock = await this.getEditorLockFailure(agentId);
if (editorLock) return editorLock;
const parsed = tryParseConfigJson(json);
const parsed = readStructuredToolInput(input);
if (!parsed.ok) return { ok: false, errors: parsed.errors };
const fresh = await this.getFreshConfigSnapshot(agentId, projectId, baseConfigHash);
if (!fresh.ok) return fresh;
const validated = this.validateBuilderConfig(parsed.data, fresh.snapshot.config);
const merged = mergeOmittedTopLevelFields(parsed.data, fresh.snapshot.config);
const validated = this.validateBuilderConfig(merged, fresh.snapshot.config);
if (!validated.ok) return validated;
// Compare with the merged config, so kept fields alone do not echo the full config back.
return await this.saveBuilderConfig(
agentId,
projectId,
user,
validated.config,
fresh.snapshot.configHash,
parsed.data,
merged,
);
}
@ -1413,12 +1472,12 @@ export class AgentsBuilderToolsService {
agentId: string,
projectId: string,
user: User,
operations: string,
operations: unknown,
baseConfigHash: string | null,
) {
const editorLock = await this.getEditorLockFailure(agentId);
if (editorLock) return { ...editorLock, stage: 'locked' };
const parsed = tryParseConfigJson(operations);
const parsed = readStructuredToolInput(operations);
if (!parsed.ok) return { ok: false, stage: 'parse', errors: parsed.errors };
const fresh = await this.getFreshConfigSnapshot(agentId, projectId, baseConfigHash);
if (!fresh.ok) return fresh;

View File

@ -12,8 +12,12 @@ Use this after deciding a config change is needed and before calling
### Workflow
Follow Config Freshness for authoritative reads, hashes, and stale recovery.
- For \`write_config\`, send the complete config JSON string plus \`baseConfigHash\`.
- For \`patch_config\`, send RFC 6902 operations as a JSON string plus \`baseConfigHash\`.
- For \`write_config\`, send a \`config\` object with only the top-level fields
you set or change, plus \`baseConfigHash\`. Omitted top-level fields keep
their stored value, so do not copy \`model\`, \`credential\`, \`memory\`,
\`personalisation\`, \`config\`, or \`integrations\` back unless you change them.
- For \`patch_config\`, send an array of RFC 6902 operation objects plus \`baseConfigHash\`.
- Send objects and arrays, not JSON strings.
- Pass the \`configHash\` from your latest config read or from your latest
write result as \`baseConfigHash\`; do not read the config again between your
own writes.
@ -140,7 +144,9 @@ Bad: replacing \`config\` while dropping unrelated settings
### Gotchas
- \`write_config\` replaces the full config; include every field that should survive.
- \`write_config\` keeps every top-level field you omit. A top-level field you
send replaces the stored field whole: to change one key inside \`config\`
(for example \`config.webSearch\`), use \`patch_config\` on that path instead.
- \`patch_config\` cannot create a config when none exists; use \`write_config\` first.
- \`/array/-\` appends to an array; \`/array/0\` inserts before the current first item.
- Removing an integration means deleting its entry from \`integrations[]\`; do