diff --git a/packages/persona-kit/schemas/agent.schema.json b/packages/persona-kit/schemas/agent.schema.json index eca01204..51bb0df6 100644 --- a/packages/persona-kit/schemas/agent.schema.json +++ b/packages/persona-kit/schemas/agent.schema.json @@ -48,12 +48,17 @@ }, "where": { "type": "string" + }, + "maxConcurrency": { + "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": [ "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/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/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/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`); diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e482efd6..2c618bb5 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,41 @@ 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: 'absent' }, + { 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: 'absent' }, + { 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' }), @@ -701,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 3fbb30df..37b08183 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,21 @@ 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) && + 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; } /**