Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/persona-kit/schemas/agent.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/persona-kit/scripts/emit-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/persona-kit/src/define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
1 change: 1 addition & 0 deletions packages/persona-kit/src/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface TypedTrigger<P extends string> {
on: TriggerNameFor<P>;
match?: string;
where?: string;
maxConcurrency?: number;
}

/**
Expand Down
22 changes: 22 additions & 0 deletions packages/persona-kit/src/emit-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> & {
Expand All @@ -150,6 +168,7 @@ type SchemaNode = Record<string, unknown> & {
enum?: unknown[];
const?: unknown;
type?: string | string[];
minimum?: number;
properties?: Record<string, SchemaNode | boolean>;
additionalProperties?: SchemaNode | boolean;
required?: string[];
Expand Down Expand Up @@ -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`);
Expand Down
39 changes: 38 additions & 1 deletion packages/persona-kit/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]
},
Expand All @@ -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' }),
Expand All @@ -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({}), {});
});
Expand Down
15 changes: 13 additions & 2 deletions packages/persona-kit/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand All @@ -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 }
: {})
};
}

Expand Down
7 changes: 7 additions & 0 deletions packages/persona-kit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading