mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
perf(core): Send agent builder config writes as objects with changed fields only (#40666)
This commit is contained in:
parent
983f5ecea8
commit
2aa3fbf185
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: ""\`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user