diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 7417ec98ee9..d9a5ee7e350 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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, ); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index 343f6c2fc58..4eaa2c19bb5 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -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: ""\`. diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 32170bf898a..1ce15541a70 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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; diff --git a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts index 87d3029838f..f9ce7fa94b2 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts @@ -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