From a7d3e6d4d09e3a3b9ae057f26cb25e6681d61193 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 12 Jun 2026 12:43:21 +0200 Subject: [PATCH 1/3] Preserve trigger maxConcurrency in persona-kit --- .../persona-kit/schemas/agent.schema.json | 6 +++- packages/persona-kit/src/define.test.ts | 3 +- packages/persona-kit/src/define.ts | 1 + packages/persona-kit/src/parse.test.ts | 29 ++++++++++++++++++- packages/persona-kit/src/parse.ts | 13 +++++++-- packages/persona-kit/src/types.ts | 7 +++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index eca01204..48042d42 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -48,12 +48,16 @@ }, "where": { "type": "string" + }, + "maxConcurrency": { + "type": "number", + "description": "Optional delivery backpressure hint for this trigger. Positive integers are preserved by the parser; invalid values are treated as unset so older or malformed specs do not block delivery." } }, "required": [ "on" ], - "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" }" + "description": "A single event trigger declared by an **agent** (`agent.ts`). `on` is a Relayfile-adapter-normalized event name (e.g. `pull_request.opened`, `issue.create`, `message.created`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nTriggers live on the agent ( {@link AgentSpec.triggers } ), not the persona — the persona only declares which providers it connects to ( {@link PersonaIntegrationConfig } ). The deploy CLI joins the two: events from `agent.triggers[provider]`, connection/scope from `persona.integrations[provider]`.\n\nExamples: { on: \"pull_request.opened\" } { on: \"pull_request_review_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" } { on: \"message.created\", maxConcurrency: 1 }" }, "PersonaSchedule": { "type": "object", diff --git a/packages/persona-kit/src/define.test.ts b/packages/persona-kit/src/define.test.ts index 4addb54f..f883471a 100644 --- a/packages/persona-kit/src/define.test.ts +++ b/packages/persona-kit/src/define.test.ts @@ -67,10 +67,11 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers { on: 'AgentSessionEvent.prompted' }, { on: 'AppUserNotification.issueCommentMention' } ], - slack: [{ on: 'message.created' }], + slack: [{ on: 'message.created', maxConcurrency: 1 }], customProvider: [{ on: 'custom.event' }] }; assert.equal(triggers.github?.[1]?.on, 'off_registry.github_event'); + assert.equal(triggers.slack?.[0]?.maxConcurrency, 1); assert.equal(triggers.customProvider?.[0]?.on, 'custom.event'); }); diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts index 038c276d..76eb2adb 100644 --- a/packages/persona-kit/src/define.ts +++ b/packages/persona-kit/src/define.ts @@ -23,6 +23,7 @@ export interface TypedTrigger

{ on: TriggerNameFor

; match?: string; where?: string; + maxConcurrency?: number; } /** diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e482efd6..fc9902d0 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -675,7 +675,7 @@ test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedule triggers: { github: [ { on: 'pull_request.opened' }, - { on: 'issue_comment.created', match: '@mention' } + { on: 'issue_comment.created', match: '@mention', maxConcurrency: 1 } ], slack: [{ on: 'app_mention' }] }, @@ -684,12 +684,39 @@ test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedule }); assert.equal(agent.triggers?.github.length, 2); assert.equal(agent.triggers?.github[1].match, '@mention'); + assert.equal(agent.triggers?.github[1].maxConcurrency, 1); assert.equal(agent.triggers?.slack[0].on, 'app_mention'); assert.equal(agent.schedules?.[0].name, 'nightly'); assert.equal(agent.watch?.[0].paths[0], '/github/x.json'); assert.equal(agent.launchedBy, 'team-dispatcher'); }); +test('parseAgentSpec omits invalid trigger maxConcurrency values', () => { + const agent = parseAgentSpec({ + triggers: { + github: [ + { on: 'valid', maxConcurrency: 2 }, + { on: 'zero', maxConcurrency: 0 }, + { on: 'negative', maxConcurrency: -1 }, + { on: 'fractional', maxConcurrency: 1.5 }, + { on: 'nan', maxConcurrency: NaN }, + { on: 'infinity', maxConcurrency: Infinity }, + { on: 'string', maxConcurrency: '1' } + ] + } + }); + + assert.deepEqual(agent.triggers?.github, [ + { on: 'valid', maxConcurrency: 2 }, + { on: 'zero' }, + { on: 'negative' }, + { on: 'fractional' }, + { on: 'nan' }, + { on: 'infinity' }, + { on: 'string' } + ]); +}); + test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => { assert.throws( () => parseAgentSpec({ launchedBy: 'cron' }), diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 3fbb30df..17228337 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -543,7 +543,7 @@ export function parseIntegrationTrigger( if (!isObject(value)) { throw new Error(`${context} must be an object`); } - const { on, match, where } = value; + const { on, match, where, maxConcurrency } = value; if (typeof on !== 'string' || !on.trim()) { throw new Error(`${context}.on must be a non-empty string`); } @@ -553,10 +553,19 @@ export function parseIntegrationTrigger( if (where !== undefined && (typeof where !== 'string' || !where.trim())) { throw new Error(`${context}.where must be a non-empty string if provided`); } + const parsedMaxConcurrency = + typeof maxConcurrency === 'number' && + Number.isInteger(maxConcurrency) && + maxConcurrency >= 1 + ? maxConcurrency + : undefined; return { on, ...(typeof match === 'string' ? { match } : {}), - ...(typeof where === 'string' ? { where } : {}) + ...(typeof where === 'string' ? { where } : {}), + ...(parsedMaxConcurrency !== undefined + ? { maxConcurrency: parsedMaxConcurrency } + : {}) }; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 97803762..0c002daa 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -194,11 +194,18 @@ export type McpServerSpec = * { on: "pull_request.opened" } * { on: "pull_request_review_comment.created", match: "@mention" } * { on: "check_run.completed", where: "conclusion=failure" } + * { on: "message.created", maxConcurrency: 1 } */ export interface PersonaIntegrationTrigger { on: string; match?: string; where?: string; + /** + * Optional delivery backpressure hint for this trigger. Positive integers are + * preserved by the parser; invalid values are treated as unset so older or + * malformed specs do not block delivery. + */ + maxConcurrency?: number; } /** From 866e65f910d987bd7359fde550b47b4fabf70eb8 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 12 Jun 2026 12:51:44 +0200 Subject: [PATCH 2/3] Document maxConcurrency leniency --- packages/persona-kit/src/parse.test.ts | 10 ++++++++++ packages/persona-kit/src/parse.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index fc9902d0..2c618bb5 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -695,6 +695,7 @@ test('parseAgentSpec omits invalid trigger maxConcurrency values', () => { const agent = parseAgentSpec({ triggers: { github: [ + { on: 'absent' }, { on: 'valid', maxConcurrency: 2 }, { on: 'zero', maxConcurrency: 0 }, { on: 'negative', maxConcurrency: -1 }, @@ -707,6 +708,7 @@ test('parseAgentSpec omits invalid trigger maxConcurrency values', () => { }); assert.deepEqual(agent.triggers?.github, [ + { on: 'absent' }, { on: 'valid', maxConcurrency: 2 }, { on: 'zero' }, { on: 'negative' }, @@ -728,6 +730,14 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths', () => parseAgentSpec({ triggers: { github: [{ on: '' }] } }), /triggers\.github\[0\]\.on must be a non-empty string/ ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', match: '' }] } }), + /triggers\.github\[0\]\.match must be a non-empty string if provided/ + ); + assert.throws( + () => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', where: 1 }] } }), + /triggers\.github\[0\]\.where must be a non-empty string if provided/ + ); // An empty agent (no listeners) parses to {}; the deploy CLI enforces "at least one". assert.deepEqual(parseAgentSpec({}), {}); }); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 17228337..37b08183 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -553,6 +553,8 @@ export function parseIntegrationTrigger( if (where !== undefined && (typeof where !== 'string' || !where.trim())) { throw new Error(`${context}.where must be a non-empty string if provided`); } + // Intentionally lenient: Cloud derives this as an optional backpressure hint, + // so invalid values mean "unset" rather than a parse failure. const parsedMaxConcurrency = typeof maxConcurrency === 'number' && Number.isInteger(maxConcurrency) && From e2685af4883903a0c1820f8b015b7618cb7373bb Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Fri, 12 Jun 2026 11:04:16 +0000 Subject: [PATCH 3/3] chore: apply pr-reviewer fixes for #230 --- .../persona-kit/schemas/agent.schema.json | 5 +++-- packages/persona-kit/scripts/emit-schema.mjs | 12 ++++++++++ packages/persona-kit/src/emit-schema.test.ts | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index 48042d42..51bb0df6 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -50,8 +50,9 @@ "type": "string" }, "maxConcurrency": { - "type": "number", - "description": "Optional delivery backpressure hint for this trigger. Positive integers are preserved by the parser; invalid values are treated as unset so older or malformed specs do not block delivery." + "type": "integer", + "description": "Optional delivery backpressure hint for this trigger. Positive integers are preserved by the parser; invalid values are treated as unset so older or malformed specs do not block delivery.", + "minimum": 1 } }, "required": [ diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index 87856146..a046f28e 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -134,6 +134,18 @@ const agentSchema = agentGenerator.createSchema('AgentSpec'); agentSchema.$schema = 'https://json-schema.org/draft/2020-12/schema'; agentSchema.$id = 'https://agentworkforce.dev/schemas/agent.schema.json'; +const agentTrigger = agentSchema.definitions?.PersonaIntegrationTrigger; +if ( + agentTrigger && + agentTrigger.properties?.maxConcurrency?.type === 'number' +) { + agentTrigger.properties.maxConcurrency = { + ...agentTrigger.properties.maxConcurrency, + type: 'integer', + minimum: 1 + }; +} + const agentSerialized = `${JSON.stringify(agentSchema, null, 2)}\n`; let existingAgent = ''; try { diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts index ff39004f..4c419ef3 100644 --- a/packages/persona-kit/src/emit-schema.test.ts +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -138,6 +138,24 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => { assert.equal(watchRule.properties?.paths && watchRule.properties.paths !== true ? watchRule.properties.paths.type : undefined, 'array'); + const trigger = definitions.PersonaIntegrationTrigger; + const maxConcurrency = trigger.properties?.maxConcurrency; + assert.equal(maxConcurrency && maxConcurrency !== true + ? maxConcurrency.type + : undefined, 'integer'); + assert.equal(maxConcurrency && maxConcurrency !== true + ? maxConcurrency.minimum + : undefined, 1); + + assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 1 }] } }, schema, schema, 'agent'); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.maxConcurrency must be >= 1/ + ); + assert.throws( + () => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 1.5 }] } }, schema, schema, 'agent'), + /agent\.triggers\.slack\[0\]\.maxConcurrency must be integer/ + ); }); type SchemaNode = Record & { @@ -150,6 +168,7 @@ type SchemaNode = Record & { enum?: unknown[]; const?: unknown; type?: string | string[]; + minimum?: number; properties?: Record; additionalProperties?: SchemaNode | boolean; required?: string[]; @@ -189,6 +208,9 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path if (schema.type) { assertType(value, schema.type, path); } + if (typeof schema.minimum === 'number' && typeof value === 'number' && value < schema.minimum) { + throw new Error(`${path} must be >= ${schema.minimum}`); + } if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) { if (!isObject(value)) { throw new Error(`${path} must be an object`);