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: 4 additions & 3 deletions examples/linear-shipper/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ function safeRepoDirName(value: string): string {
export default defineAgent({
triggers: { linear: [{ on: 'issue.created' }] },
handler: async (ctx, event) => {
if (event.source !== 'linear' || event.type !== 'issue.created') return;
if (event.type !== 'linear.issue.created') return;

const data = (await event.expand('full')).data;
const payload =
typeof event.payload === 'object' && event.payload !== null
? (event.payload as LinearIssueEvent)
typeof data === 'object' && data !== null
? (data as LinearIssueEvent)
: {};
const issueRef = payload.issue;
const issueId = issueRef?.id ?? issueRef?.identifier;
Expand Down
9 changes: 4 additions & 5 deletions examples/notion-essay-pr/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
writeJsonFile,
type IntegrationClientOptions,
type WorkforceCtx,
type WorkforceProviderEvent
type WorkforceEvent
} from '@agentworkforce/runtime';

interface NotionPageCreatedPayload {
Expand All @@ -32,9 +32,8 @@ function vfsClient(): IntegrationClientOptions {
export default defineAgent({
triggers: { notion: [{ on: 'page.created' }] },
handler: async (ctx, event) => {
if (event.source !== 'notion' || event.type !== 'page.created') {
if (event.type !== 'notion.page.created') {
ctx.log('debug', 'notion-essay-pr.ignored', {
source: event.source,
type: event.type
});
return;
Expand All @@ -43,8 +42,8 @@ export default defineAgent({
}
});

async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProviderEvent): Promise<void> {
const payload = readPayload(event.payload);
async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceEvent): Promise<void> {
const payload = readPayload((await event.expand('full')).data);
const pageId = pageIdFrom(payload);
const pageTitle = pageTitleFrom(payload, event.summary?.title);
const pagePath = `/notion/pages/${encodeURIComponent(pageId)}.md`;
Expand Down
15 changes: 8 additions & 7 deletions examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import notionEssayPr from './agent.js';
import type { WorkforceCtx, WorkforceProviderEvent } from '@agentworkforce/runtime';
import { envelopeToAgentEvent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';

const FAKE_RECEIPT_URL = 'https://github.com/AgentWorkforce/proactive-agents/pull/17';

Expand Down Expand Up @@ -110,24 +110,25 @@ class MockNotionEssayRuntime {
}
}

async spawnAndDispatch(agent: { handler: (ctx: WorkforceCtx, event: WorkforceProviderEvent) => Promise<void> | void }): Promise<void> {
async spawnAndDispatch(agent: { handler: (ctx: WorkforceCtx, event: WorkforceEvent) => Promise<void> | void }): Promise<void> {
this.sandboxSpawned = true;
this.files.set('/workspace/AGENTS.md', '# Agent: notion-essay-pr\n');
await agent.handler(this.ctx(), {
const event = envelopeToAgentEvent({
id: 'evt-page-123',
source: 'notion',
type: 'page.created',
workspaceId: 'ws-proactive',
workspace: 'ws-proactive',
type: 'notion.page.created',
occurredAt: '2026-05-13T12:00:00.000Z',
attempt: 1,
payload: {
resource: {
pageId: 'page-123',
title: 'Launch notes'
},
summary: {
title: 'Launch notes'
}
});
if (!event) throw new Error('failed to build test event');
await agent.handler(this.ctx(), event);
}

async collectPullRequestWritebacks(): Promise<Array<Record<string, unknown>>> {
Expand Down
14 changes: 7 additions & 7 deletions examples/review-agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,24 +146,24 @@ export default defineAgent({
slack: [{ on: 'app_mention' }]
},
handler: async (ctx, event) => {
if (event.source === 'github') {
const payload = payloadOf(event.payload);
if (event.type === 'pull_request.opened') {
if (event.type.startsWith('github.')) {
const payload = payloadOf((await event.expand('full')).data);
if (event.type === 'github.pull_request.opened') {
await reviewPullRequest(ctx, payload);
return;
}
if (event.type === 'issue_comment.created' || event.type === 'pull_request_review_comment.created') {
if (event.type === 'github.issue_comment.created' || event.type === 'github.pull_request_review_comment.created') {
await replyToGithubMention(ctx, payload);
return;
}
if (event.type === 'check_run.completed') {
if (event.type === 'github.check_run.completed') {
await handleFailedCheck(ctx, payload);
return;
}
}

if (event.source === 'slack' && event.type === 'app_mention') {
await replyInSlack(ctx, payloadOf(event.payload));
if (event.type === 'slack.app_mention') {
await replyInSlack(ctx, payloadOf((await event.expand('full')).data));
}
}
});
5 changes: 3 additions & 2 deletions examples/weekly-digest/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveMountRoot,
WorkforceIntegrationError,
writeJsonFile,
isCronTickEvent,
type IntegrationClientOptions,
type WorkforceCtx,
type WorkforceEvent
Expand All @@ -25,8 +26,8 @@ interface DigestCluster {
export default defineAgent({
schedules: [{ name: 'weekly', cron: '0 9 * * 6', tz: 'UTC' }],
handler: async (ctx, event) => {
if (event.source !== 'cron' || event.name !== 'weekly') {
ctx.log('warn', 'weekly-digest.ignored', { source: event.source });
if (!isCronTickEvent(event)) {
ctx.log('warn', 'weekly-digest.ignored', { reason: 'non-cron event' });
return;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/invoke-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,13 @@ const E2E_PERSONA = {
onEvent: './agent.ts'
};

const E2E_AGENT_SRC = `import { defineAgent } from '@agentworkforce/runtime';
const E2E_AGENT_SRC = `import { defineAgent, isCronTickEvent } from '@agentworkforce/runtime';
export default defineAgent({
schedules: [{ name: 'weekly', cron: '0 9 * * 6' }],
handler: async (ctx, event) => {
ctx.log('info', 'e2e handler ran', { id: event.id });
await ctx.memory.save('e2e note');
if (event.source === 'cron' && event.name === 'explode') {
if (isCronTickEvent(event) && event.schedule === 'explode') {
throw new Error('fixture asked for failure');
}
return 'weekly digest sent';
Expand Down Expand Up @@ -228,7 +228,7 @@ test('runInvoke e2e: bundles, simulates, emits Cloud-compatible record', async (
test('runInvoke e2e: handler failure → exit 1, error in record', async () => {
await withE2EPersona(async (dir, personaPath) => {
const fixturePath = path.join(dir, 'events.ndjson');
const failing = { ...ENVELOPE, id: 'e-fail', name: 'explode' };
const failing = { ...ENVELOPE, id: 'e-fail', cron: 'explode' };
await writeFile(
fixturePath,
`${JSON.stringify(failing)}\n${JSON.stringify(ENVELOPE)}\n`,
Expand Down
46 changes: 46 additions & 0 deletions packages/persona-kit/schemas/persona.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@
"$ref": "#/definitions/PersonaMemory",
"description": "Memory subsystem opt-in. Wires the agent-assistant memory adapter at runtime; the persona spec only declares intent, not implementation details (api keys, adapter type, etc. come from workforce env)."
},
"relay": {
"$ref": "#/definitions/PersonaRelay",
"description": "Relay (agent-relay / relaycast) participation. Off unless declared. When enabled, launchers inject the relaycast MCP so the persona can post to channels, DM, and answer replies — see {@link PersonaRelayConfig } . Secrets (`RELAY_API_KEY`, `RELAY_BASE_URL`) come from workforce env, not the spec."
},
"onEvent": {
"type": "string",
"description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required by the JSON Schema whenever {@link cloud } is `true` (any cloud persona needs an entrypoint, regardless of whether triggers are declared); the deploy CLI enforces the same rule. The parser itself keeps the field optional so partially-authored specs still parse."
Expand Down Expand Up @@ -612,6 +616,48 @@
}
},
"description": "ai-memory recall config (the \"how\" + \"why\" retrieval via the ai-hist MCP). `enabled` defaults to true in object form."
},
"PersonaRelay": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/PersonaRelayConfig"
}
]
},
"PersonaRelayConfig": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Master switch. Object form defaults to enabled (`{}` ⇒ on)."
},
"agentName": {
"type": "string",
"description": "Agent identity registered on relay (`RELAY_AGENT_NAME`). Defaults to the env value, then the persona id. Must match the broker's routing name."
},
"channels": {
"type": "array",
"items": {
"type": "string"
},
"description": "Relaycast channels the persona participates in (names may omit the leading `#`). Declarative intent consumed when wiring inbox triggers (Unit C); does not affect MCP injection on its own."
},
"inbox": {
"type": "array",
"items": {
"type": "string"
},
"description": "Relaycast inbox selectors the persona subscribes to (e.g. `@self`, `#eng`), mirroring the SDK `AgentDefinition.inbox` contract."
},
"defaultWorkspace": {
"type": "string",
"description": "Default relay workspace (`RELAY_DEFAULT_WORKSPACE`); else env."
}
},
"description": "Relay (agent-relay / relaycast) participation config. Like {@link * PersonaMemoryConfig } , the persona declares **intent** only — the API key and base URL are secrets that come from workforce env (`RELAY_API_KEY`, `RELAY_BASE_URL`). When enabled, launchers inject the relaycast MCP server (see `buildRelaycastMcpServer`) so the persona is a first-class chat participant: it can post to channels, DM, and answer replies — the human-in-the-loop surface a solo persona otherwise lacks."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a typo in the JSDoc-style link in the description: {@link * PersonaMemoryConfig } contains an extra asterisk (*). It should be simplified to {@link PersonaMemoryConfig} to render correctly.

Suggested change
"description": "Relay (agent-relay / relaycast) participation config. Like {@link * PersonaMemoryConfig } , the persona declares **intent** only — the API key and base URL are secrets that come from workforce env (`RELAY_API_KEY`, `RELAY_BASE_URL`). When enabled, launchers inject the relaycast MCP server (see `buildRelaycastMcpServer`) so the persona is a first-class chat participant: it can post to channels, DM, and answer replies — the human-in-the-loop surface a solo persona otherwise lacks."
"description": "Relay (agent-relay / relaycast) participation config. Like {@link PersonaMemoryConfig } , the persona declares **intent** only — the API key and base URL are secrets that come from workforce env (`RELAY_API_KEY`, `RELAY_BASE_URL`). When enabled, launchers inject the relaycast MCP server (see `buildRelaycastMcpServer`) so the persona is a first-class chat participant: it can post to channels, DM, and answer replies — the human-in-the-loop surface a solo persona otherwise lacks."

}
},
"$id": "https://agentworkforce.dev/schemas/persona.schema.json"
Expand Down
7 changes: 6 additions & 1 deletion packages/persona-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type {
PersonaMemory,
PersonaMemoryConfig,
PersonaMemoryScope,
PersonaRelay,
PersonaRelayConfig,
PersonaTrajectoryConfig,
PersonaMount,
PersonaPermissions,
Expand Down Expand Up @@ -99,6 +101,7 @@ export {
parseMemory,
parseMount,
parseOnEvent,
parseRelay,
parsePermissions,
parsePersonaSpec,
resolveAiMemory,
Expand Down Expand Up @@ -200,7 +203,9 @@ export {
type InteractiveConfigFile,
type InteractiveSpec,
type NonInteractiveSpec,
type RelayMcpConfig
type RelayMcpConfig,
resolvePersonaRelayMcp,
type ResolveRelayMcpResult
} from './interactive-spec.js';

// Harness detection
Expand Down
52 changes: 51 additions & 1 deletion packages/persona-kit/src/interactive-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type {
Harness,
HarnessSettings,
McpServerSpec,
PersonaPermissions
PersonaPermissions,
PersonaRelay
} from './types.js';

/**
Expand Down Expand Up @@ -280,6 +281,55 @@ function buildRelaycastMcpServer(relay: RelayMcpConfig): McpServerSpec {
return { type: 'stdio', command: 'npx', args: ['-y', '@relaycast/mcp'], env };
}

/** Outcome of resolving a persona's declared `relay` against the environment. */
export type ResolveRelayMcpResult =
| { kind: 'disabled' }
| { kind: 'missing-secret'; reason: string }
| { kind: 'ready'; config: RelayMcpConfig };

/**
* Resolve a persona's declarative {@link PersonaRelay} into a
* {@link RelayMcpConfig} the launcher can hand to {@link buildInteractiveSpec}
* as `relayMcp`. The persona declares **intent** (enabled, agentName, default
* workspace); the secret (`RELAY_API_KEY`) and base URL come from `env`.
*
* Returns a discriminated result so callers can distinguish "off" from
* "declared but the deploy env is missing `RELAY_API_KEY`" (worth a warning)
* rather than silently dropping relay. Pure — reads only the passed `env`.
*/
export function resolvePersonaRelayMcp(
relay: PersonaRelay | undefined,
env: Record<string, string | undefined>,
fallbackAgentName?: string
): ResolveRelayMcpResult {
if (relay === undefined || relay === false) return { kind: 'disabled' };
const cfg = typeof relay === 'object' ? relay : {};
if (cfg.enabled === false) return { kind: 'disabled' };

const apiKey = env.RELAY_API_KEY?.trim();
const agentName = cfg.agentName?.trim() || env.RELAY_AGENT_NAME?.trim() || fallbackAgentName?.trim();
if (!apiKey) {
return { kind: 'missing-secret', reason: 'RELAY_API_KEY is not set in the deploy environment' };
}
if (!agentName) {
return {
kind: 'missing-secret',
reason: 'no relay agent name (set relay.agentName, RELAY_AGENT_NAME, or pass the persona id)'
};
}
const baseUrl = env.RELAY_BASE_URL?.trim();
const defaultWorkspace = cfg.defaultWorkspace?.trim() || env.RELAY_DEFAULT_WORKSPACE?.trim();
return {
kind: 'ready',
config: {
apiKey,
agentName,
...(baseUrl ? { baseUrl } : {}),
...(defaultWorkspace ? { defaultWorkspace } : {})
}
};
}

/**
* Build the stdio MCP server spec for ai-hist — the unified retrieval surface
* that serves both the "why" (this persona's compacted decision trajectories)
Expand Down
Loading
Loading