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
37 changes: 37 additions & 0 deletions packages/deploy/src/compile-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,43 @@ test('unknown capability extensions survive source-to-persistence projection', a
}
});

test('trigger paths survive source-to-persistence projection for wake scoping', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-trigger-paths-'));
try {
const sourcePath = path.join(dir, 'agent.ts');
const source = PRESET
.replace('integrations: { github: {} },', 'integrations: { github: {}, slack: {} },')
.replace(
"triggers: { github: [{ on: 'pull_request.opened' }] },",
"triggers: {\n" +
" github: [{ on: 'pull_request.opened', paths: ['/github/repos/AgentWorkforce/workforce/pulls/**'] }],\n" +
" slack: [{ on: 'message.created', paths: ['/slack/channels/C_REVIEW/**'] }]\n" +
' },'
);
await writeFile(sourcePath, source);

const compiled = await compileAgentSource(sourcePath);
const persisted = projectCompiledAgentForPersistence(compiled);

assert.deepEqual(persisted.agent.triggers, {
github: [
{
on: 'pull_request.opened',
paths: ['/github/repos/AgentWorkforce/workforce/pulls/**']
}
],
slack: [
{
on: 'message.created',
paths: ['/slack/channels/C_REVIEW/**']
}
]
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test('split persona plus agent form remains supported', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'compiled-agent-split-'));
try {
Expand Down
19 changes: 16 additions & 3 deletions packages/persona-kit/schemas/agent.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@
"where": {
"type": "string"
},
"paths": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$"
},
"description": "Optional Relayfile path globs used to scope wake routing before an agent is provisioned. Paths are absolute (leading `/`) and provider-specific.",
"minItems": 1
},
"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.",
Expand All @@ -58,7 +68,7 @@
"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\" } { on: \"message.created\", maxConcurrency: 1 }"
"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\", paths: [\"/slack/channels/C_REVIEW/**\"] } { on: \"message.created\", maxConcurrency: 1 }"
},
"PersonaSchedule": {
"type": "object",
Expand All @@ -85,8 +95,11 @@
"paths": {
"type": "array",
"items": {
"type": "string"
}
"type": "string",
"minLength": 1,
"pattern": "^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$"
},
"minItems": 1
},
"events": {
"type": "array",
Expand Down
17 changes: 17 additions & 0 deletions packages/persona-kit/scripts/emit-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ if (
};
}

const absolutePathArrayConstraint = {
minItems: 1,
items: {
type: 'string',
minLength: 1,
pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$'
}
};

function applyAbsolutePathArrayConstraint(pathsSchema) {
if (pathsSchema?.type !== 'array') return;
Object.assign(pathsSchema, absolutePathArrayConstraint);
}

applyAbsolutePathArrayConstraint(agentTrigger?.properties?.paths);
applyAbsolutePathArrayConstraint(agentSchema.definitions?.WatchRule?.properties?.paths);

const agentSerialized = `${JSON.stringify(agentSchema, null, 2)}\n`;
let existingAgent = '';
try {
Expand Down
9 changes: 8 additions & 1 deletion packages/persona-kit/src/define.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,18 @@ test('TypedTriggerMap gives per-provider event autocomplete; arbitrary providers
{ on: 'AgentSessionEvent.prompted' },
{ on: 'AppUserNotification.issueCommentMention' }
],
slack: [{ on: 'message.created', maxConcurrency: 1 }],
slack: [
{
on: 'message.created',
paths: ['/slack/channels/C_REVIEW/**'],
maxConcurrency: 1
}
],
customProvider: [{ on: 'custom.event' }]
};
assert.equal(triggers.github?.[1]?.on, 'off_registry.github_event');
assert.equal(triggers.slack?.[0]?.maxConcurrency, 1);
assert.deepEqual(triggers.slack?.[0]?.paths, ['/slack/channels/C_REVIEW/**']);
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 @@ -24,6 +24,7 @@ export interface TypedTrigger<P extends string> {
on: TriggerNameFor<P>;
match?: string;
where?: string;
paths?: string[];
maxConcurrency?: number;
}

Expand Down
94 changes: 93 additions & 1 deletion packages/persona-kit/src/emit-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,29 @@ 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 absolutePathItems = {
type: 'string',
minLength: 1,
pattern: '^/(?:[^\\r\\n\\u2028\\u2029]*\\S)?$'
};
const watchPaths = watchRule.properties?.paths;
assert.equal(watchPaths && watchPaths !== true
? watchPaths.minItems
: undefined, 1);
assert.deepEqual(watchPaths && watchPaths !== true
? watchPaths.items
: undefined, absolutePathItems);
const trigger = definitions.PersonaIntegrationTrigger;
const triggerPaths = trigger.properties?.paths;
assert.equal(triggerPaths && triggerPaths !== true
? triggerPaths.type
: undefined, 'array');
assert.equal(triggerPaths && triggerPaths !== true
? triggerPaths.minItems
: undefined, 1);
assert.deepEqual(triggerPaths && triggerPaths !== true
? triggerPaths.items
: undefined, absolutePathItems);
const maxConcurrency = trigger.properties?.maxConcurrency;
assert.equal(maxConcurrency && maxConcurrency !== true
? maxConcurrency.type
Expand All @@ -168,7 +190,65 @@ test('agent schema exposes launchedBy/triggers/schedules/watch', async () => {
? maxConcurrency.minimum
: undefined, 1);

assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 1 }] } }, schema, schema, 'agent');
assertSchema(
{
triggers: {
slack: [
{
on: 'message.created',
paths: ['/ slack/channels/C_REVIEW/**'],
maxConcurrency: 1
}
]
},
watch: [{ paths: ['/ github/**'], events: ['created'] }]
},
schema,
schema,
'agent'
);
assert.throws(
() => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: [] }] } }, schema, schema, 'agent'),
/agent\.triggers\.slack\[0\]\.paths must have at least 1 item/
);
assert.throws(
() => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['slack/**'] }] } }, schema, schema, 'agent'),
/agent\.triggers\.slack\[0\]\.paths\[0\] must match/
);
assert.throws(
() => assertSchema({ triggers: { slack: [{ on: 'message.created', paths: ['/slack/** '] }] } }, schema, schema, 'agent'),
/agent\.triggers\.slack\[0\]\.paths\[0\] must match/
);
assert.throws(
() => assertSchema({ watch: [{ paths: [], events: ['created'] }] }, schema, schema, 'agent'),
/agent\.watch\[0\]\.paths must have at least 1 item/
);
assert.throws(
() => assertSchema({ watch: [{ paths: ['watch/**'], events: ['created'] }] }, schema, schema, 'agent'),
/agent\.watch\[0\]\.paths\[0\] must match/
);
assert.throws(
() => assertSchema({ watch: [{ paths: [' /watch/**'], events: ['created'] }] }, schema, schema, 'agent'),
/agent\.watch\[0\]\.paths\[0\] must match/
);
assert.throws(
() => assertSchema({ watch: [{ paths: ['/watch/** '], events: ['created'] }] }, schema, schema, 'agent'),
/agent\.watch\[0\]\.paths\[0\] must match/
);
for (const separator of ['\r', '\n', '\u2028', '\u2029']) {
assert.throws(
() => assertSchema({
triggers: { slack: [{ on: 'message.created', paths: [`/slack/${separator}channels/**`] }] }
}, schema, schema, 'agent'),
/agent\.triggers\.slack\[0\]\.paths\[0\] must match/
);
assert.throws(
() => assertSchema({
watch: [{ paths: [`/github/${separator}issues/**`], events: ['created'] }]
}, schema, schema, 'agent'),
/agent\.watch\[0\]\.paths\[0\] must match/
);
}
assert.throws(
() => assertSchema({ triggers: { slack: [{ on: 'message.created', maxConcurrency: 0 }] } }, schema, schema, 'agent'),
/agent\.triggers\.slack\[0\]\.maxConcurrency must be >= 1/
Expand All @@ -190,6 +270,9 @@ type SchemaNode = Record<string, unknown> & {
const?: unknown;
type?: string | string[];
minimum?: number;
minItems?: number;
minLength?: number;
pattern?: string;
properties?: Record<string, SchemaNode | boolean>;
additionalProperties?: SchemaNode | boolean;
required?: string[];
Expand Down Expand Up @@ -232,6 +315,12 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path
if (typeof schema.minimum === 'number' && typeof value === 'number' && value < schema.minimum) {
throw new Error(`${path} must be >= ${schema.minimum}`);
}
if (typeof schema.minLength === 'number' && typeof value === 'string' && value.length < schema.minLength) {
throw new Error(`${path} must have at least ${schema.minLength} character(s)`);
}
if (schema.pattern && typeof value === 'string' && !new RegExp(schema.pattern).test(value)) {
throw new Error(`${path} must match ${schema.pattern}`);
}
if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) {
if (!isObject(value)) {
throw new Error(`${path} must be an object`);
Expand All @@ -256,6 +345,9 @@ function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path
if (!Array.isArray(value)) {
throw new Error(`${path} must be an array`);
}
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
throw new Error(`${path} must have at least ${schema.minItems} item(s)`);
}
if (schema.items) {
value.forEach((item, index) =>
assertSchema(item, schema.items as SchemaNode, root, `${path}[${index}]`)
Expand Down
56 changes: 54 additions & 2 deletions packages/persona-kit/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,17 +774,26 @@ test('parseAgentSpec validates launchedBy plus provider-keyed triggers, schedule
triggers: {
github: [
{ on: 'pull_request.opened' },
{ on: 'issue_comment.created', match: '@mention', maxConcurrency: 1 }
{
on: 'issue_comment.created',
match: '@mention',
paths: ['/github/repos/AgentWorkforce/workforce/issues/**'],
maxConcurrency: 1
}
],
slack: [{ on: 'app_mention' }]
slack: [{ on: 'app_mention', paths: ['/slack/channels/C_REVIEW/**'] }]
},
schedules: [{ name: 'nightly', cron: '0 2 * * *', tz: 'UTC' }],
watch: [{ paths: ['/github/x.json'], events: ['created'] }]
});
assert.equal(agent.triggers?.github.length, 2);
assert.equal(agent.triggers?.github[1].match, '@mention');
assert.deepEqual(agent.triggers?.github[1].paths, [
'/github/repos/AgentWorkforce/workforce/issues/**'
]);
assert.equal(agent.triggers?.github[1].maxConcurrency, 1);
assert.equal(agent.triggers?.slack[0].on, 'app_mention');
assert.deepEqual(agent.triggers?.slack[0].paths, ['/slack/channels/C_REVIEW/**']);
assert.equal(agent.schedules?.[0].name, 'nightly');
assert.equal(agent.watch?.[0].paths[0], '/github/x.json');
assert.equal(agent.launchedBy, 'team-dispatcher');
Expand Down Expand Up @@ -837,6 +846,38 @@ test('parseAgentSpec rejects malformed triggers maps with precise field paths',
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', where: 1 }] } }),
/triggers\.github\[0\]\.where must be a non-empty string if provided/
);
assert.throws(
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: '/*' }] } }),
/triggers\.github\[0\]\.paths must be a non-empty array/
);
assert.throws(
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [] }] } }),
/triggers\.github\[0\]\.paths must be a non-empty array/
);
assert.throws(
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: [''] }] } }),
/triggers\.github\[0\]\.paths\[0\] must be a non-empty string/
);
assert.throws(
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['/github/** '] }] } }),
/triggers\.github\[0\]\.paths\[0\] must not have leading or trailing whitespace/
);
assert.throws(
() => parseAgentSpec({ triggers: { github: [{ on: 'issue.opened', paths: ['github/**'] }] } }),
/triggers\.github\[0\]\.paths\[0\] must start with \//
);
const triggerWithInternalSpace = parseAgentSpec({
triggers: { github: [{ on: 'issue.opened', paths: ['/ github/**'] }] }
});
assert.deepEqual(triggerWithInternalSpace.triggers?.github[0].paths, ['/ github/**']);
for (const separator of ['\r', '\n', '\u2028', '\u2029']) {
assert.throws(
() => parseAgentSpec({
triggers: { github: [{ on: 'issue.opened', paths: [`/github/${separator}issues/**`] }] }
}),
/triggers\.github\[0\]\.paths\[0\] must not contain line separators/
);
}
// An empty agent (no listeners) parses to {}; the deploy CLI enforces "at least one".
assert.deepEqual(parseAgentSpec({}), {});
});
Expand Down Expand Up @@ -1038,12 +1079,23 @@ test('parseWatch rejects malformed relayfile watch rules with precise field path
assert.throws(() => parseWatch([{ events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/);
assert.throws(() => parseWatch([{ paths: [], events: ['created'] }], 'watch'), /watch\[0\]\.paths must be a non-empty array/);
assert.throws(() => parseWatch([{ paths: [42], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must be a non-empty string/);
assert.throws(() => parseWatch([{ paths: ['/x '], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must not have leading or trailing whitespace/);
assert.throws(() => parseWatch([{ paths: ['relative/**'], events: ['created'] }], 'watch'), /watch\[0\]\.paths\[0\] must start with \//);
assert.throws(() => parseWatch([{ paths: ['/x'] }], 'watch'), /watch\[0\]\.events must be a non-empty array/);
assert.throws(() => parseWatch([{ paths: ['/x'], events: [] }], 'watch'), /watch\[0\]\.events must be a non-empty array/);
assert.throws(() => parseWatch([{ paths: ['/x'], events: ['changed'] }], 'watch'), /watch\[0\]\.events\[0\] must be one of: created, updated, deleted/);
assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], debounceMs: -1 }], 'watch'), /watch\[0\]\.debounceMs must be a non-negative number/);
assert.throws(() => parseWatch([{ paths: ['/x'], events: ['created'], match: '' }], 'watch'), /watch\[0\]\.match must be a non-empty string/);
assert.deepEqual(
parseWatch([{ paths: ['/ github/**'], events: ['created'] }], 'watch')?.[0].paths,
['/ github/**']
);
for (const separator of ['\r', '\n', '\u2028', '\u2029']) {
assert.throws(
() => parseWatch([{ paths: [`/github/${separator}issues/**`], events: ['created'] }], 'watch'),
/watch\[0\]\.paths\[0\] must not contain line separators/
);
}
});

test('parsePersonaSpec round-trips mount.enabled=false (watch now lives on the agent)', () => {
Expand Down
Loading
Loading