diff --git a/examples/linear-shipper/agent.ts b/examples/linear-shipper/agent.ts index ce829e12..8f40d869 100644 --- a/examples/linear-shipper/agent.ts +++ b/examples/linear-shipper/agent.ts @@ -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; diff --git a/examples/notion-essay-pr/agent.ts b/examples/notion-essay-pr/agent.ts index 3c658d24..fe8aa26f 100644 --- a/examples/notion-essay-pr/agent.ts +++ b/examples/notion-essay-pr/agent.ts @@ -6,7 +6,7 @@ import { writeJsonFile, type IntegrationClientOptions, type WorkforceCtx, - type WorkforceProviderEvent + type WorkforceEvent } from '@agentworkforce/runtime'; interface NotionPageCreatedPayload { @@ -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; @@ -43,8 +42,8 @@ export default defineAgent({ } }); -async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceProviderEvent): Promise { - const payload = readPayload(event.payload); +async function handleNotionPageCreated(ctx: WorkforceCtx, event: WorkforceEvent): Promise { + 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`; diff --git a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts index b5dfa7d1..f55f0299 100644 --- a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts +++ b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts @@ -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'; @@ -110,17 +110,16 @@ class MockNotionEssayRuntime { } } - async spawnAndDispatch(agent: { handler: (ctx: WorkforceCtx, event: WorkforceProviderEvent) => Promise | void }): Promise { + async spawnAndDispatch(agent: { handler: (ctx: WorkforceCtx, event: WorkforceEvent) => Promise | void }): Promise { 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' }, @@ -128,6 +127,8 @@ class MockNotionEssayRuntime { title: 'Launch notes' } }); + if (!event) throw new Error('failed to build test event'); + await agent.handler(this.ctx(), event); } async collectPullRequestWritebacks(): Promise>> { diff --git a/examples/review-agent/agent.ts b/examples/review-agent/agent.ts index 62338171..1a2271b3 100644 --- a/examples/review-agent/agent.ts +++ b/examples/review-agent/agent.ts @@ -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)); } } }); diff --git a/examples/weekly-digest/agent.ts b/examples/weekly-digest/agent.ts index 81274bd3..d210f7ac 100644 --- a/examples/weekly-digest/agent.ts +++ b/examples/weekly-digest/agent.ts @@ -5,6 +5,7 @@ import { resolveMountRoot, WorkforceIntegrationError, writeJsonFile, + isCronTickEvent, type IntegrationClientOptions, type WorkforceCtx, type WorkforceEvent @@ -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; } diff --git a/packages/cli/src/invoke-command.test.ts b/packages/cli/src/invoke-command.test.ts index d4486d56..328850fb 100644 --- a/packages/cli/src/invoke-command.test.ts +++ b/packages/cli/src/invoke-command.test.ts @@ -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'; @@ -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`, diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 73a794dd..32572532 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -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." @@ -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." } }, "$id": "https://agentworkforce.dev/schemas/persona.schema.json" diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 034504de..fc7ba80a 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -36,6 +36,8 @@ export type { PersonaMemory, PersonaMemoryConfig, PersonaMemoryScope, + PersonaRelay, + PersonaRelayConfig, PersonaTrajectoryConfig, PersonaMount, PersonaPermissions, @@ -99,6 +101,7 @@ export { parseMemory, parseMount, parseOnEvent, + parseRelay, parsePermissions, parsePersonaSpec, resolveAiMemory, @@ -200,7 +203,9 @@ export { type InteractiveConfigFile, type InteractiveSpec, type NonInteractiveSpec, - type RelayMcpConfig + type RelayMcpConfig, + resolvePersonaRelayMcp, + type ResolveRelayMcpResult } from './interactive-spec.js'; // Harness detection diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 74b1d7b7..7bbd722e 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -2,7 +2,8 @@ import type { Harness, HarnessSettings, McpServerSpec, - PersonaPermissions + PersonaPermissions, + PersonaRelay } from './types.js'; /** @@ -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, + 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) diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 833142fa..3fbb30df 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -25,6 +25,8 @@ import type { PersonaMemory, PersonaMemoryConfig, PersonaMemoryScope, + PersonaRelay, + PersonaRelayConfig, PersonaMount, PersonaTrajectoryConfig, PersonaPermissions, @@ -913,6 +915,56 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un return out; } +export function parseRelay(value: unknown, context: string): PersonaRelay | undefined { + if (value === undefined) return undefined; + if (typeof value === 'boolean') return value; + if (!isObject(value)) { + throw new Error(`${context} must be a boolean or an object if provided`); + } + const { enabled, agentName, channels, inbox, defaultWorkspace } = value; + const out: PersonaRelayConfig = {}; + if (enabled !== undefined) { + if (typeof enabled !== 'boolean') { + throw new Error(`${context}.enabled must be a boolean if provided`); + } + out.enabled = enabled; + } + if (agentName !== undefined) { + if (typeof agentName !== 'string' || !agentName.trim()) { + throw new Error(`${context}.agentName must be a non-empty string if provided`); + } + out.agentName = agentName.trim(); + } + if (channels !== undefined) { + out.channels = parseRelayStringList(channels, `${context}.channels`); + } + if (inbox !== undefined) { + out.inbox = parseRelayStringList(inbox, `${context}.inbox`); + } + if (defaultWorkspace !== undefined) { + if (typeof defaultWorkspace !== 'string' || !defaultWorkspace.trim()) { + throw new Error(`${context}.defaultWorkspace must be a non-empty string if provided`); + } + out.defaultWorkspace = defaultWorkspace.trim(); + } + return out; +} + +function parseRelayStringList(value: unknown, context: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`${context} must be an array of strings if provided`); + } + const out: string[] = []; + for (const [idx, entry] of value.entries()) { + if (typeof entry !== 'string' || !entry.trim()) { + throw new Error(`${context}[${idx}] must be a non-empty string`); + } + const normalized = entry.trim(); + if (!out.includes(normalized)) out.push(normalized); + } + return out; +} + function parseTrajectoryConfig( value: unknown, context: string @@ -1090,6 +1142,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): integrations, capabilities, memory, + relay, onEvent } = value; @@ -1197,6 +1250,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): `persona[${expectedIntent}].capabilities` ); const parsedMemory = parseMemory(memory, `persona[${expectedIntent}].memory`); + const parsedRelay = parseRelay(relay, `persona[${expectedIntent}].relay`); const parsedOnEvent = parseOnEvent(onEvent, `persona[${expectedIntent}].onEvent`); const parsedSandbox = parseSandbox(sandbox, `persona[${expectedIntent}].sandbox`); @@ -1227,6 +1281,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): ...(parsedIntegrations ? { integrations: parsedIntegrations } : {}), ...(parsedCapabilities ? { capabilities: parsedCapabilities } : {}), ...(parsedMemory !== undefined ? { memory: parsedMemory } : {}), + ...(parsedRelay !== undefined ? { relay: parsedRelay } : {}), ...(parsedOnEvent !== undefined ? { onEvent: parsedOnEvent } : {}) }; } diff --git a/packages/persona-kit/src/relay.test.ts b/packages/persona-kit/src/relay.test.ts new file mode 100644 index 00000000..43bdbdbf --- /dev/null +++ b/packages/persona-kit/src/relay.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseRelay, parsePersonaSpec } from './parse.js'; +import { resolvePersonaRelayMcp } from './interactive-spec.js'; + +test('parseRelay accepts the boolean shorthand', () => { + assert.equal(parseRelay(true, 'p.relay'), true); + assert.equal(parseRelay(false, 'p.relay'), false); + assert.equal(parseRelay(undefined, 'p.relay'), undefined); +}); + +test('parseRelay parses and normalizes the object form', () => { + const r = parseRelay( + { enabled: true, agentName: ' granola ', channels: ['#eng', 'eng', 'ops'], inbox: ['@self'] }, + 'p.relay' + ); + assert.deepEqual(r, { + enabled: true, + agentName: 'granola', + channels: ['#eng', 'eng', 'ops'], + inbox: ['@self'] + }); +}); + +test('parseRelay rejects malformed shapes', () => { + assert.throws(() => parseRelay(42, 'p.relay'), /must be a boolean or an object/); + assert.throws(() => parseRelay({ enabled: 'yes' }, 'p.relay'), /enabled must be a boolean/); + assert.throws(() => parseRelay({ agentName: ' ' }, 'p.relay'), /agentName must be a non-empty/); + assert.throws(() => parseRelay({ channels: 'eng' }, 'p.relay'), /channels must be an array/); + assert.throws(() => parseRelay({ inbox: [''] }, 'p.relay'), /inbox\[0\] must be a non-empty/); +}); + +test('parsePersonaSpec threads the relay field through', () => { + const spec = parsePersonaSpec( + { + id: 'granola-prospect', + intent: 'relay-orchestrator', + description: 'x', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + onEvent: './agent.ts', + relay: { channels: ['eng'] } + }, + 'relay-orchestrator' + ); + assert.deepEqual(spec.relay, { channels: ['eng'] }); +}); + +test('resolvePersonaRelayMcp returns disabled when off', () => { + assert.equal(resolvePersonaRelayMcp(undefined, {}).kind, 'disabled'); + assert.equal(resolvePersonaRelayMcp(false, { RELAY_API_KEY: 'k' }).kind, 'disabled'); + assert.equal(resolvePersonaRelayMcp({ enabled: false }, { RELAY_API_KEY: 'k' }).kind, 'disabled'); +}); + +test('resolvePersonaRelayMcp flags a missing secret rather than dropping silently', () => { + const r = resolvePersonaRelayMcp(true, {}, 'granola'); + assert.equal(r.kind, 'missing-secret'); + if (r.kind === 'missing-secret') assert.match(r.reason, /RELAY_API_KEY/); +}); + +test('resolvePersonaRelayMcp merges declared intent with env secrets', () => { + const r = resolvePersonaRelayMcp( + { channels: ['eng'], defaultWorkspace: 'acme' }, + { RELAY_API_KEY: 'sk-1', RELAY_BASE_URL: 'https://relay.example' }, + 'granola-prospect' + ); + assert.equal(r.kind, 'ready'); + if (r.kind !== 'ready') return; + assert.deepEqual(r.config, { + apiKey: 'sk-1', + agentName: 'granola-prospect', // fell back to persona id + baseUrl: 'https://relay.example', + defaultWorkspace: 'acme' // declared value wins + }); +}); + +test('resolvePersonaRelayMcp: explicit agentName and env workspace', () => { + const r = resolvePersonaRelayMcp( + { agentName: 'reviewer' }, + { RELAY_API_KEY: 'sk-2', RELAY_DEFAULT_WORKSPACE: 'ws-env' } + ); + assert.equal(r.kind, 'ready'); + if (r.kind !== 'ready') return; + assert.equal(r.config.agentName, 'reviewer'); + assert.equal(r.config.defaultWorkspace, 'ws-env'); +}); diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 9046393b..97803762 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -363,6 +363,40 @@ export interface PersonaAiMemoryConfig { export type PersonaMemory = boolean | PersonaMemoryConfig; +/** + * 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. + */ +export interface PersonaRelayConfig { + /** Master switch. Object form defaults to enabled (`{}` ⇒ on). */ + enabled?: boolean; + /** + * Agent identity registered on relay (`RELAY_AGENT_NAME`). Defaults to the + * env value, then the persona id. Must match the broker's routing name. + */ + agentName?: string; + /** + * 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. + */ + channels?: string[]; + /** + * Relaycast inbox selectors the persona subscribes to (e.g. `@self`, `#eng`), + * mirroring the SDK `AgentDefinition.inbox` contract. + */ + inbox?: string[]; + /** Default relay workspace (`RELAY_DEFAULT_WORKSPACE`); else env. */ + defaultWorkspace?: string; +} + +export type PersonaRelay = boolean | PersonaRelayConfig; + export type CapabilityValue = | boolean | { enabled?: boolean; [k: string]: unknown }; @@ -544,6 +578,13 @@ export interface PersonaSpec { * details (api keys, adapter type, etc. come from workforce env). */ memory?: PersonaMemory; + /** + * 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. + */ + relay?: PersonaRelay; /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1905bcd1..32f8690b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "@agent-assistant/proactive": "^0.4.32", + "@agent-relay/events": "^6.3.3", "@agentworkforce/persona-kit": "workspace:*", "@relayfile/adapter-core": "^0.3.29", "agent-trajectories": "^0.5.3" diff --git a/packages/runtime/src/define-agent.test.ts b/packages/runtime/src/define-agent.test.ts index a11b4409..79f124f2 100644 --- a/packages/runtime/src/define-agent.test.ts +++ b/packages/runtime/src/define-agent.test.ts @@ -56,14 +56,14 @@ test('defineAgent narrows the handler event type to declared triggers/schedules' triggers: { github: [{ on: 'pull_request.opened' }] }, schedules: [{ name: 'nightly', cron: '0 2 * * *' }], handler: async (_ctx, event) => { - if (event.source === 'cron') { - // `name` is narrowed to the declared schedule names. - const name: 'nightly' = event.name; - void name; - } else { - // `type` is narrowed to the declared trigger `on` values. - const type: 'pull_request.opened' = event.type; - void type; + // v4: `event.type` is narrowed to the provider-qualified trigger types + // plus `cron.tick` — the relay SDK / cloud envelope form. + const type: 'github.pull_request.opened' | 'cron.tick' = event.type; + void type; + if (event.type === 'github.pull_request.opened') { + // Provider payload is reached via expand, not a sync `.payload`. + const full = await event.expand('full'); + void full.data; } } }); @@ -79,33 +79,29 @@ test('defineAgent narrows Linear agent-session payloads for declared triggers', ] }, handler: async (_ctx, event) => { - if (event.source !== 'linear') return; - if (event.type === 'AgentSessionEvent.created') { - const record = unwrapResourceRecord(event.payload); + // v4: trigger `on` literals narrow `event.type` to the provider-qualified + // `linear.` form; the payload data comes from `expand('full')`. + const type: + | 'linear.AgentSessionEvent.created' + | 'linear.AgentSessionEvent.prompted' + | 'linear.AppUserNotification.issueCommentMention' = event.type; + void type; + if (event.type === 'linear.AgentSessionEvent.created') { + const record = unwrapResourceRecord((await event.expand('full')).data); if (!isLinearAgentSessionPayload(record)) return; const sessionId: string = record.agentSession.id; - const promptContext: string | undefined = record.promptContext; void sessionId; - void promptContext; return; } - if (event.type === 'AgentSessionEvent.prompted') { - const record = unwrapResourceRecord(event.payload); + if (event.type === 'linear.AgentSessionEvent.prompted') { + const record = unwrapResourceRecord((await event.expand('full')).data); if (!isLinearAgentSessionPayload(record)) return; - const body: string | undefined = record.agentActivity?.body; - void body; + void record.agentActivity?.body; return; } - const record = unwrapResourceRecord(event.payload); + const record = unwrapResourceRecord((await event.expand('full')).data); if (!isLinearAppUserNotificationPayload(record)) return; - const issueId: string | undefined = - record.notification?.issue?.id ?? - record.issue?.id; - const commentBody: string | undefined = - record.notification?.comment?.body ?? - record.comment?.body; - void issueId; - void commentBody; + void (record.notification?.issue?.id ?? record.issue?.id); } }); }); diff --git a/packages/runtime/src/define-agent.ts b/packages/runtime/src/define-agent.ts index fb09188d..f864d18d 100644 --- a/packages/runtime/src/define-agent.ts +++ b/packages/runtime/src/define-agent.ts @@ -5,14 +5,11 @@ import type { WatchRule } from '@agentworkforce/persona-kit'; import { handler as brandHandler } from './handler.js'; +import type { AgentEvent, BaseAgentEvent, EventType } from '@agent-relay/events'; import type { - WorkforceCronEvent, WorkforceCtx, - WorkforceEvent, WorkforceHandler, - WorkforceHandlerExport, - LinearAgentSessionEvent, - WorkforceProviderEvent + WorkforceHandlerExport } from './types.js'; // --------------------------------------------------------------------------- @@ -20,12 +17,11 @@ import type { // // `defineAgent` flows the literal triggers/schedules an author declares into // the handler's `event` parameter so `event.type` autocompletes to exactly the -// declared trigger `on` values and `event.name` to the declared schedule -// names. This is best-effort: when neither triggers nor schedules are present -// (or the literals can't be resolved), the handler falls back to the full -// `WorkforceEvent` union. `source` is intentionally left at its base type — -// agents trigger on providers outside the closed `WorkforceEventSource` enum -// (e.g. `granola`, `google-mail`), so narrowing it would fight reality. +// fully-qualified event types the agent fires on (e.g. a `github` trigger with +// `on: 'pull_request.opened'` narrows `event.type` to +// `github.pull_request.opened` — the relay SDK / cloud envelope form). When +// nothing narrowable is declared, the handler falls back to the full relay SDK +// {@link AgentEvent}. The payload is read via `await event.expand('full')`. // --------------------------------------------------------------------------- /** Distributive union of every `on` literal declared across a triggers map. */ @@ -37,13 +33,13 @@ type OnLiteralsOf = A extends readonly (infer E)[] type TriggerOnUnion = OnLiteralsOf>; -type LinearSpecialEvent = Extract; - -type ProviderEventFor

= P extends 'linear' - ? [LinearSpecialEvent] extends [never] - ? Omit & { type: O } - : LinearSpecialEvent - : Omit & { type: O }; +// A provider trigger `{ github: [{ on: 'pull_request.opened' }] }` narrows to +// the SDK event whose `type` is the provider-qualified `github.pull_request.opened`. +// Uses BaseAgentEvent so `event.type` is the exact declared literal and works +// for any provider event shape — including 2-segment types like +// `slack.app_mention` that aren't valid `EventType`s (those collapse to `never` +// under `AgentEvent<...>`). +type ProviderEventFor

= BaseAgentEvent<`${P}.${O}`>; type TriggerProviderEvents = { [P in keyof Tr]: P extends string @@ -66,19 +62,21 @@ type ScheduleNameUnion = S extends readonly (infer E)[] : never : never; +// Any declared schedule fires `cron.tick` events. type NarrowedCronEvent = [ScheduleNameUnion] extends [never] ? never - : Omit & { name: ScheduleNameUnion }; + : AgentEvent & { type: 'cron.tick' }; /** - * The discriminated event a `defineAgent` handler receives, narrowed to the - * declared triggers/schedules. Falls back to the full {@link WorkforceEvent} - * union when nothing narrowable is declared. + * The event a `defineAgent` handler receives, narrowed by `event.type` to the + * declared triggers/schedules. Falls back to the full relay SDK + * {@link AgentEvent} when nothing narrowable is declared. (Renamed from the + * pre-v4 `AgentEvent` to avoid colliding with the SDK's `AgentEvent`.) */ -export type AgentEvent = [ +export type WorkforceEventFor = [ NarrowedProviderEvent | NarrowedCronEvent ] extends [never] - ? WorkforceEvent + ? AgentEvent : NarrowedProviderEvent | NarrowedCronEvent; /** @@ -107,7 +105,7 @@ export interface AgentDefinition< /** Relayfile-change listeners. */ watch?: readonly WatchRule[]; /** Event handler. `event` is narrowed to the declared triggers/schedules. */ - handler: (ctx: WorkforceCtx, event: AgentEvent) => Promise | void; + handler: (ctx: WorkforceCtx, event: WorkforceEventFor) => Promise | void; } /** diff --git a/packages/runtime/src/envelope-fields.cloud.ts b/packages/runtime/src/envelope-fields.cloud.ts index 18873e5e..56d2d302 100644 --- a/packages/runtime/src/envelope-fields.cloud.ts +++ b/packages/runtime/src/envelope-fields.cloud.ts @@ -15,5 +15,16 @@ */ export const CLOUD_ENVELOPE_FIELDS = { always: ["id", "workspace", "type", "occurredAt", "attempt", "name", "cron", "resource"], - optional: ["provider", "eventType", "deliveryId", "paths", "summary", "resumeContext"], + optional: [ + "provider", + "eventType", + "deliveryId", + "paths", + "summary", + "resumeContext", + "harnessSession", + "channel", + "messageId", + "threadId", + ], } as const; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f1981f3a..1fdf2fa0 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -10,7 +10,7 @@ export { defineAgent, isWorkforceAgent, type AgentDefinition, - type AgentEvent, + type WorkforceEventFor, type WorkforceAgentExport } from './define-agent.js'; @@ -34,14 +34,26 @@ export type { WorkflowContext, WorkflowRunHandle, WorkforceAgentContext, - WorkforceCronEvent, WorkforceCtx, WorkforceDeploymentContext, WorkforceEvent, - WorkforceEventSource, WorkforceHandler, WorkforceHandlerExport, - WorkforceProviderEvent + AgentEvent, + EventType, + CronTickEvent, + RelaycastMessageEvent, + RelayfileChangeEvent, + StartupEvent +} from './types.js'; + +// Relay SDK event type guards, re-exported so persona handlers can narrow +// `event` by type without importing `@agent-relay/events` directly. +export { + isCronTickEvent, + isRelaycastMessageEvent, + isRelayfileChangeEvent, + isStartupEvent } from './types.js'; // Runtime envelope helpers shared by provider-triggered agents. @@ -50,8 +62,9 @@ export { } from './types.js'; // Raw gateway envelope contract (the runner's stdin NDJSON line shape, and -// the fixture format for invocation simulation). -export { shimEnvelope, type RawGatewayEnvelope } from './shim.js'; +// the fixture format for invocation simulation) + the envelope→AgentEvent decoder. +export { type RawGatewayEnvelope } from './shim.js'; +export { envelopeToAgentEvent } from './to-agent-event.js'; export type { LinearAgentActivity, diff --git a/packages/runtime/src/raw.ts b/packages/runtime/src/raw.ts index 665e5aca..870cbcec 100644 --- a/packages/runtime/src/raw.ts +++ b/packages/runtime/src/raw.ts @@ -1,18 +1,25 @@ /** - * Lower-level escape hatch for power users who want the cloud - * proactive-runtime SDK directly without workforce's `handler()` / - * `WorkforceCtx` ergonomics. When `@agent-relay/agent` is published, this - * file should switch to: + * Lower-level escape hatch for power users who want to drive the cloud + * proactive-runtime contract directly, without workforce's `handler()` / + * `WorkforceCtx` ergonomics. * - * export { agent } from '@agent-relay/agent'; + * Note: this is deliberately NOT a re-export of `@agent-relay/agent`'s + * `agent()`. That package is published, but it exposes a hosted-runtime + * surface (`AgentDefinition`/`AgentHandle`/`deployAgent`) keyed off the + * broker's *decoded* event types. Workforce instead owns its own decode + * path (`shimEnvelope` + `buildCtx` + `startRunner`) because the wire + * format it consumes is cloud's gateway envelope contract + * (`envelope-fields.cloud.ts`, cloud#1841), not an SDK type. Keeping this + * layer workforce-owned is what lets the runtime stay decoupled from the + * relay agent SDK's versioning. * - * Until that package is available on npm, this module exposes the same - * shapes as documented in `cloud-proactive-runtime-spec/docs/proactive- - * runtime/spec.md` so users can write code against the contract today - * and swap the import once the SDK ships. + * The shapes here match `cloud-proactive-runtime-spec/docs/proactive- + * runtime/spec.md`. Power users who specifically want the broker's hosted + * `agent()` should import `@agent-relay/agent` themselves. */ -export { shimEnvelope, type RawGatewayEnvelope } from './shim.js'; +export { type RawGatewayEnvelope } from './shim.js'; +export { envelopeToAgentEvent } from './to-agent-event.js'; export type { CtxBuildOptions } from './ctx.js'; export { buildCtx } from './ctx.js'; export { startRunner, type StartRunnerOptions } from './runner.js'; diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index 3b7f83fb..4d96bac3 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -10,6 +10,7 @@ import { startRunner } from './runner.js'; import { createCloudRuntimeDefaults } from './cloud-defaults.js'; import { buildCtx } from './ctx.js'; import { handler } from './handler.js'; +import { isCronTickEvent } from '@agent-relay/events'; import type { RawGatewayEnvelope } from './shim.js'; import type { SandboxContext, @@ -94,9 +95,9 @@ test('startRunner dispatches a cron envelope to the handler', async () => { ]) }); assert.equal(received.length, 1); - assert.equal(received[0].source, 'cron'); - if (received[0].source !== 'cron') return; - assert.equal(received[0].name, 'weekly'); + assert.ok(isCronTickEvent(received[0])); + if (!isCronTickEvent(received[0])) return; + assert.equal(received[0].schedule, '0 9 * * 6'); assert.ok(logs.find((l) => l.message === 'runner.handler.ok')); }); @@ -142,7 +143,8 @@ test('startRunner skips envelopes that the shim can not translate', async () => log: (level, message) => logs.push({ level, message }) }, envelopes: streamOf([ - { id: 'e1', workspace: 'ws-test', type: 'mystery.thing', occurredAt: 'x' }, + // No dot, and not a known single-word type → the mapper drops it. + { id: 'e1', workspace: 'ws-test', type: 'mystery', occurredAt: 'x' }, { id: 'e2', workspace: 'ws-test', type: 'cron.tick', occurredAt: 'x', name: 'tick' } ]) }); diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index 100aa588..995c315f 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -3,7 +3,9 @@ import { createCloudRuntimeDefaults } from './cloud-defaults.js'; import { buildCtx, type CtxBuildOptions } from './ctx.js'; import { getTrajectoryRecorder, type TrajectoryRecorder } from './trajectory.js'; import { isWorkforceHandler } from './handler.js'; -import { shimEnvelope, type RawGatewayEnvelope } from './shim.js'; +import { type RawGatewayEnvelope } from './shim.js'; +import { envelopeToAgentEvent } from './to-agent-event.js'; +import { isCronTickEvent } from '@agent-relay/events'; import type { HarnessRunArgs, HarnessRunResult, @@ -142,7 +144,7 @@ export async function startRunner(options: StartRunnerOptions): Promise { const recorder = getTrajectoryRecorder(ctx); const stream = options.envelopes ?? readEnvelopesFromStdin(); for await (const raw of stream) { - const event = shimEnvelope(raw); + const event = envelopeToAgentEvent(raw); if (!event) { ctx.log('warn', 'runner.envelope.unsupported', { rawId: raw.id, rawType: raw.type }); continue; @@ -172,8 +174,8 @@ async function dispatch( await fn(ctx, event); ctx.log('info', 'runner.handler.ok', { eventId: event.id, - source: event.source, - type: event.source === 'cron' ? 'cron.tick' : event.type, + source: isCronTickEvent(event) ? 'cron' : event.resource.provider, + type: event.type, durationMs: Date.now() - t0 }); // Auto-finalize (no-op if the handler already called ctx.trajectory.done). @@ -181,8 +183,8 @@ async function dispatch( } catch (err) { ctx.log('error', 'runner.handler.error', { eventId: event.id, - source: event.source, - type: event.source === 'cron' ? 'cron.tick' : event.type, + source: isCronTickEvent(event) ? 'cron' : event.resource.provider, + type: event.type, attempt: event.attempt, durationMs: Date.now() - t0, error: err instanceof Error ? err.message : String(err), diff --git a/packages/runtime/src/shim.contract.test.ts b/packages/runtime/src/shim.contract.test.ts index 04d82bf8..aa1fb609 100644 --- a/packages/runtime/src/shim.contract.test.ts +++ b/packages/runtime/src/shim.contract.test.ts @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CLOUD_ENVELOPE_FIELDS } from './envelope-fields.cloud.js'; -import { RAW_GATEWAY_ENVELOPE_FIELDS, shimEnvelope, type RawGatewayEnvelope } from './shim.js'; +import { RAW_GATEWAY_ENVELOPE_FIELDS, type RawGatewayEnvelope } from './shim.js'; +import { envelopeToAgentEvent } from './to-agent-event.js'; /** * Cross-repo envelope contract test (workforce#189): every field cloud's @@ -32,7 +33,7 @@ test('cloud field lists are disjoint and non-empty (copy sanity)', () => { } }); -test('a full cloud-shaped envelope (all contract fields) shims without loss of dispatch', () => { +test('a full cloud-shaped envelope (all contract fields) shims without loss of dispatch', async () => { // An exported fixture from `runs export` carries the cloud-only fields; // shimEnvelope must still dispatch it (unknown-to-dispatch fields are // simply not consumed — replay fidelity comes from `resource`). @@ -52,11 +53,11 @@ test('a full cloud-shaped envelope (all contract fields) shims without loss of d summary: { title: 'x' }, resumeContext: { phase: 2 }, }; - const event = shimEnvelope(envelope); + const event = envelopeToAgentEvent(envelope); assert.ok(event); if (!event) return; - assert.equal(event.source, 'github'); - if (event.source !== 'github') return; - assert.equal(event.type, 'pull_request.opened'); - assert.deepEqual(event.payload, { action: 'opened' }); + assert.equal(event.type, 'github.pull_request.opened'); + assert.equal(event.resource.provider, 'github'); + const full = await event.expand('full'); + assert.deepEqual(full.data, { action: 'opened' }); }); diff --git a/packages/runtime/src/shim.test.ts b/packages/runtime/src/shim.test.ts index 9c53fd1a..7e26a302 100644 --- a/packages/runtime/src/shim.test.ts +++ b/packages/runtime/src/shim.test.ts @@ -1,78 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { shimEnvelope } from './shim.js'; import { handler, isWorkforceHandler } from './handler.js'; -test('shimEnvelope translates a cron.tick envelope', () => { - const ev = shimEnvelope({ - id: 'evt-1', - workspace: 'ws-acme', - type: 'cron.tick', - occurredAt: '2026-05-12T09:00:00Z', - name: 'weekly', - cron: '0 9 * * 6' - }); - assert.ok(ev); - assert.equal(ev.source, 'cron'); - if (ev.source !== 'cron') return; - assert.equal(ev.name, 'weekly'); - assert.equal(ev.cron, '0 9 * * 6'); - assert.equal(ev.workspaceId, 'ws-acme'); - assert.equal(ev.attempt, 1); -}); - -test('shimEnvelope translates a provider envelope with summary', () => { - const ev = shimEnvelope({ - id: 'evt-7', - workspace: 'ws-acme', - type: 'github.pull_request.opened', - occurredAt: '2026-05-12T10:00:00Z', - attempt: 2, - resource: { pr: { number: 42 } }, - summary: { title: 'Add deploy', actor: 'kgnt' } - }); - assert.ok(ev); - if (ev.source === 'cron') { - assert.fail('expected provider event, got cron'); - } - assert.equal(ev.source, 'github'); - assert.equal(ev.type, 'pull_request.opened'); - assert.equal(ev.attempt, 2); - assert.deepEqual(ev.summary, { title: 'Add deploy', actor: 'kgnt' }); -}); - -test('shimEnvelope falls back to attempt=1 and a generated occurredAt when missing', () => { - const before = Date.now(); - const ev = shimEnvelope({ - id: 'evt-x', - workspace: 'ws-a', - type: 'linear.issue.created', - occurredAt: undefined as unknown as string - }); - assert.ok(ev); - if (ev.source === 'cron') return; - assert.equal(ev.attempt, 1); - const occurredAtMs = Date.parse(ev.occurredAt); - assert.ok(Number.isFinite(occurredAtMs)); - assert.ok(occurredAtMs >= before - 1000); -}); - -test('shimEnvelope returns null for unknown sources and malformed envelopes', () => { - assert.equal( - shimEnvelope({ id: 'e', workspace: 'w', type: 'mystery.event.fired', occurredAt: 'x' }), - null - ); - assert.equal(shimEnvelope({ id: '', workspace: 'w', type: 'cron.tick', occurredAt: 'x' }), null); - assert.equal(shimEnvelope({ id: 'e', workspace: '', type: 'cron.tick', occurredAt: 'x' }), null); - assert.equal(shimEnvelope({ id: 'e', workspace: 'w', type: '', occurredAt: 'x' }), null); -}); - -test('shimEnvelope returns null when provider event has no event-name suffix', () => { - assert.equal( - shimEnvelope({ id: 'e', workspace: 'w', type: 'github.', occurredAt: 'x' }), - null - ); -}); +// Envelope → event decoding moved to `to-agent-event.ts` in v4 and is covered +// by `to-agent-event.test.ts`. This file now only covers handler branding. test('handler() brands a function and round-trips identity', () => { let called = false; diff --git a/packages/runtime/src/shim.ts b/packages/runtime/src/shim.ts index a53e248c..9f1a5e62 100644 --- a/packages/runtime/src/shim.ts +++ b/packages/runtime/src/shim.ts @@ -1,18 +1,15 @@ -import type { - WorkforceCronEvent, - WorkforceEvent, - WorkforceEventSource, - WorkforceProviderEvent -} from './types.js'; - /** * Raw envelope shape the cloud proactive-runtime gateway delivers per M1 * spec (`cloud-proactive-runtime-spec/docs/proactive-runtime/spec.md`). - * Kept as a structural type rather than imported from `@agent-relay/agent` - * so the runtime compiles even when that package isn't yet published. * - * Once `@agent-relay/agent` ships, the shim swaps its internal envelope - * type for the SDK's published one — call sites stay unchanged. + * This is workforce's own type, not an import from `@agent-relay/agent` — + * and intentionally so. The relay agent SDK exports *decoded* events + * (`CronTickEvent`, `RelayfileChangeEvent`, …) but no raw-envelope type; + * the wire format below is cloud's gateway contract, mirrored here and + * pinned to `envelope-fields.cloud.ts` (cloud#1841) via + * `shim.contract.test.ts`. There is nothing in the SDK to "swap to" — the + * runtime owns this decode boundary so it can track the cloud gateway + * contract independently of the SDK's version. */ export interface RawGatewayEnvelope { id: string; @@ -39,6 +36,14 @@ export interface RawGatewayEnvelope { paths?: string[]; /** Opaque resume token for multi-phase deliveries (pr-reviewer resume). */ resumeContext?: unknown; + /** Harness resume session cloud attaches for multi-phase deliveries. */ + harnessSession?: unknown; + /** Relaycast channel (or DM id) for `relaycast.message` envelopes. */ + channel?: string; + /** Relaycast message id for `relaycast.message` envelopes. */ + messageId?: string; + /** Relaycast thread id for `relaycast.message` envelopes, when threaded. */ + threadId?: string; } /** @@ -65,83 +70,17 @@ export const RAW_GATEWAY_ENVELOPE_FIELDS = [ 'paths', 'summary', 'resumeContext', + 'harnessSession', + 'channel', + 'messageId', + 'threadId', // Declared on the frame but never emitted by cloud's buildEnvelope — // kept for older gateway shapes; not part of the cloud contract. 'expand', 'digest', ] as const satisfies readonly (keyof RawGatewayEnvelope)[]; -type ProviderSource = Exclude; - -const PROVIDER_SOURCES: ReadonlySet = new Set([ - 'github', - 'linear', - 'slack', - 'notion', - 'jira' -]); - -function isProviderSource(value: string): value is ProviderSource { - return PROVIDER_SOURCES.has(value as ProviderSource); -} - -/** - * Translate a raw gateway envelope into a discriminated WorkforceEvent. - * - * Returns `null` for envelope shapes the v1 runtime does not yet know how - * to dispatch — the caller logs and acks (we don't want to crash-loop the - * runner on an envelope from a newer gateway). - */ -export function shimEnvelope(env: RawGatewayEnvelope): WorkforceEvent | null { - if (typeof env.id !== 'string' || !env.id) return null; - if (typeof env.workspace !== 'string' || !env.workspace) return null; - if (typeof env.type !== 'string' || !env.type) return null; - - const attempt = typeof env.attempt === 'number' && env.attempt > 0 ? env.attempt : 1; - const occurredAt = typeof env.occurredAt === 'string' ? env.occurredAt : new Date().toISOString(); - - if (env.type === 'cron.tick' || env.type.startsWith('cron.')) { - const cron: WorkforceCronEvent = { - source: 'cron', - id: env.id, - occurredAt, - attempt, - workspaceId: env.workspace, - name: typeof env.name === 'string' ? env.name : extractCronName(env.type), - cron: typeof env.cron === 'string' ? env.cron : '' - }; - return cron; - } - - // Provider envelopes are typed as `.` — e.g. - // `github.pull_request.opened`. Split once on the first dot. - const firstDot = env.type.indexOf('.'); - if (firstDot <= 0) return null; - const providerCandidate = env.type.slice(0, firstDot); - if (!isProviderSource(providerCandidate)) return null; - const eventType = env.type.slice(firstDot + 1); - // Guard against envelopes like `github.` where the source is valid but - // the event-name suffix is missing. The runtime should not dispatch an - // empty `event.type` to handlers — better to drop the envelope and let - // it surface in the unsupported-envelope log. - if (!eventType) return null; - - const providerEvent: WorkforceProviderEvent = { - source: providerCandidate, - id: env.id, - occurredAt, - attempt, - workspaceId: env.workspace, - type: eventType, - payload: env.resource ?? null, - ...(env.summary ? { summary: env.summary } : {}) - }; - return providerEvent; -} - -function extractCronName(typeStr: string): string { - // Accepts both `cron.tick` (no name) and `cron.tick:` form - // observed in some adapter outputs. - const colon = typeStr.indexOf(':'); - return colon > 0 ? typeStr.slice(colon + 1) : ''; -} +// v4: the envelope → event decoder lives in `to-agent-event.ts` +// (`envelopeToAgentEvent`), which produces the relay SDK's `AgentEvent`. The +// pre-v4 `shimEnvelope` (which produced the removed `WorkforceEvent` shape) is +// gone; this module now only owns the `RawGatewayEnvelope` wire contract. diff --git a/packages/runtime/src/simulate/simulate.test.ts b/packages/runtime/src/simulate/simulate.test.ts index dc0c1814..fd6c1029 100644 --- a/packages/runtime/src/simulate/simulate.test.ts +++ b/packages/runtime/src/simulate/simulate.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { handler } from '../handler.js'; +import { isCronTickEvent } from '@agent-relay/events'; import type { RawGatewayEnvelope } from '../shim.js'; import { deriveSimulatedRunFailureClass } from './failure-class.js'; import { simulateInvocation } from './simulate.js'; @@ -96,7 +97,7 @@ test('simulateInvocation: handler throw → failed run, replay continues', async const result = await simulateInvocation({ persona, handler: handler(async (_ctx, event) => { - if (event.source === 'cron') throw new Error('boom on cron'); + if (isCronTickEvent(event)) throw new Error('boom on cron'); }), envelopes: [cronEnvelope, githubEnvelope], runIdFactory: deterministicIds(), @@ -185,7 +186,7 @@ test('simulateInvocation: seeded files are readable; VFS persists across envelop const result = await simulateInvocation({ persona, handler: handler(async (ctx, event) => { - if (event.source === 'cron') { + if (isCronTickEvent(event)) { reads.push(await ctx.files.read('/seeded.json')); await ctx.files.write('/from-event-1.txt', 'carried over'); } else { @@ -206,7 +207,9 @@ test('simulateInvocation: unsupported envelopes are reported, not dispatched', a const unknownEnvelope: RawGatewayEnvelope = { id: 'e3', workspace: 'ws-test', - type: 'unknownprovider.something', + // No dot and not a known single-word type (cron.*/startup/relaycast.message/ + // user_reply) → the v4 mapper can't normalize it and drops it. + type: 'unknownthing', occurredAt: '2026-05-12T11:00:00Z' }; let invocations = 0; @@ -223,7 +226,7 @@ test('simulateInvocation: unsupported envelopes are reported, not dispatched', a assert.equal(invocations, 1); assert.equal(result.summary.total, 1); assert.equal(result.summary.unsupported, 1); - assert.deepEqual(result.unsupported, [{ id: 'e3', type: 'unknownprovider.something' }]); + assert.deepEqual(result.unsupported, [{ id: 'e3', type: 'unknownthing' }]); assert.equal(result.exitCode, 0); // unsupported is not a failure }); diff --git a/packages/runtime/src/simulate/simulate.ts b/packages/runtime/src/simulate/simulate.ts index ee2ceb9d..ef9eea65 100644 --- a/packages/runtime/src/simulate/simulate.ts +++ b/packages/runtime/src/simulate/simulate.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto'; import { buildCtx } from '../ctx.js'; import { isWorkforceHandler } from '../handler.js'; -import { shimEnvelope, type RawGatewayEnvelope } from '../shim.js'; +import { type RawGatewayEnvelope } from '../shim.js'; +import { envelopeToAgentEvent } from '../to-agent-event.js'; +import { isCronTickEvent } from '@agent-relay/events'; import type { WorkforceAgentContext, WorkforceDeploymentContext, @@ -101,7 +103,7 @@ export async function simulateInvocation( const unsupported: UnsupportedEnvelope[] = []; for (const raw of envelopes) { - const event = shimEnvelope(raw); + const event = envelopeToAgentEvent(raw); if (!event) { unsupported.push({ id: raw.id ?? '(missing id)', type: raw.type ?? '(missing type)' }); continue; @@ -135,7 +137,7 @@ export async function simulateInvocation( durationMs: runEnded.getTime() - runStarted.getTime(), trigger: { kind: explicitTriggerKind ?? triggerKindForEvent(event), - eventSource: event.source + eventSource: isCronTickEvent(event) ? 'cron' : event.resource.provider }, sandbox: { id: null, name: 'local-simulation' }, failureClass: deriveSimulatedRunFailureClass({ status, error }), @@ -206,7 +208,7 @@ function summaryFromHandlerReturn(returned: unknown): string | null { * by the caller). */ function triggerKindForEvent(event: WorkforceEvent): string { - return event.source === 'cron' ? 'clock' : 'inbox'; + return isCronTickEvent(event) ? 'clock' : 'inbox'; } function renderLogLines( diff --git a/packages/runtime/src/to-agent-event.test.ts b/packages/runtime/src/to-agent-event.test.ts new file mode 100644 index 00000000..888cf919 --- /dev/null +++ b/packages/runtime/src/to-agent-event.test.ts @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isCronTickEvent, isRelaycastMessageEvent, isStartupEvent } from '@agent-relay/events'; +import { envelopeToAgentEvent } from './to-agent-event.js'; + +test('envelopeToAgentEvent maps a cron.tick envelope to a CronTickEvent', () => { + const ev = envelopeToAgentEvent({ + id: 'evt-1', + workspace: 'ws-acme', + type: 'cron.tick', + occurredAt: '2026-05-12T09:00:00Z', + name: 'weekly', + cron: '0 9 * * 6' + }); + assert.ok(ev); + assert.ok(isCronTickEvent(ev)); + if (!isCronTickEvent(ev)) return; + assert.equal(ev.type, 'cron.tick'); + assert.equal(ev.schedule, '0 9 * * 6'); + assert.equal(ev.workspace, 'ws-acme'); + assert.equal(ev.id, 'evt-1'); + assert.equal(ev.attempt, 1); +}); + +test('envelopeToAgentEvent maps a startup envelope to a StartupEvent', () => { + const ev = envelopeToAgentEvent({ + id: 'evt-boot', + workspace: 'ws-acme', + type: 'startup', + occurredAt: '2026-05-12T08:00:00Z' + }); + assert.ok(ev); + assert.ok(isStartupEvent(ev)); +}); + +test('envelopeToAgentEvent maps a provider envelope, deriving resource + kind', () => { + const ev = envelopeToAgentEvent({ + id: 'evt-7', + workspace: 'ws-acme', + type: 'github.pull_request.opened', + occurredAt: '2026-05-12T10:00:00Z', + attempt: 2, + provider: 'github', + deliveryId: 'gh-delivery-99', + paths: ['/github/pull_requests/42'], + resource: { pr: { number: 42 } }, + summary: { title: 'Add deploy' } + }); + assert.ok(ev); + assert.equal(ev.type, 'github.pull_request.opened'); + assert.equal(ev.attempt, 2); + assert.equal(ev.resource.provider, 'github'); + assert.equal(ev.resource.kind, 'github.pull_request'); + assert.equal(ev.resource.id, 'gh-delivery-99'); + assert.equal(ev.resource.path, '/github/pull_requests/42'); + assert.deepEqual(ev.summary, { title: 'Add deploy' }); +}); + +test('envelopeToAgentEvent exposes the cloud payload via expand("full")', async () => { + const ev = envelopeToAgentEvent({ + id: 'evt-8', + workspace: 'ws-acme', + type: 'linear.issue.created', + occurredAt: '2026-05-12T10:00:00Z', + resource: { issue: { identifier: 'ENG-1' } } + }); + assert.ok(ev); + const full = await ev.expand('full'); + assert.equal(full.level, 'full'); + assert.deepEqual(full.data, { issue: { identifier: 'ENG-1' } }); +}); + +test('envelopeToAgentEvent reads first-class channel/messageId/threadId (Unit B)', () => { + // Cloud now surfaces these top-level on the envelope; the mapper should + // prefer them over the nested `resource` copy. + const ev = envelopeToAgentEvent({ + id: 'evt-top', + workspace: 'ws-acme', + type: 'relaycast.message', + occurredAt: '2026-05-12T11:00:00Z', + channel: 'eng', + messageId: 'm-top', + threadId: 't-top', + resource: { channel: 'stale', messageId: 'stale', text: 'hi' } + }); + assert.ok(ev); + assert.ok(isRelaycastMessageEvent(ev)); + if (!isRelaycastMessageEvent(ev)) return; + assert.equal(ev.channel, 'eng'); + assert.equal(ev.messageId, 'm-top'); + assert.equal(ev.threadId, 't-top'); +}); + +test('envelopeToAgentEvent maps a relaycast.message envelope (fields ride in resource)', () => { + // Shape the proactive-runtime HTTP gateway delivers: the relay-native + // envelope is nested under `resource` (resource: payload.resource ?? payload). + const ev = envelopeToAgentEvent({ + id: 'evt-msg', + workspace: 'ws-acme', + type: 'relaycast.message', + occurredAt: '2026-05-12T11:00:00Z', + resource: { + channel: 'eng', + messageId: 'm-123', + threadId: 't-9', + text: 'can you take a look?' + } + }); + assert.ok(ev); + assert.ok(isRelaycastMessageEvent(ev)); + if (!isRelaycastMessageEvent(ev)) return; + assert.equal(ev.type, 'relaycast.message'); + assert.equal(ev.channel, 'eng'); + assert.equal(ev.messageId, 'm-123'); + assert.equal(ev.threadId, 't-9'); + assert.equal(ev.resource.provider, 'relaycast'); +}); + +test('envelopeToAgentEvent maps a user_reply continuation to a relaycast message', () => { + const ev = envelopeToAgentEvent({ + id: 'evt-reply', + workspace: 'ws-acme', + type: 'user_reply', + occurredAt: '2026-05-12T11:05:00Z', + resource: { message: { id: 'm-456', text: 'yes, go ahead' } }, + resumeContext: { threadId: 't-9' } + }); + assert.ok(ev); + assert.ok(isRelaycastMessageEvent(ev)); + if (!isRelaycastMessageEvent(ev)) return; + assert.equal(ev.messageId, 'm-456'); + assert.equal(ev.threadId, 't-9'); +}); + +test('envelopeToAgentEvent drops malformed envelopes', () => { + assert.equal(envelopeToAgentEvent({ id: '', workspace: 'ws', type: 'cron.tick' } as never), null); + assert.equal(envelopeToAgentEvent({ id: 'x', workspace: '', type: 'cron.tick' } as never), null); + assert.equal(envelopeToAgentEvent({ id: 'x', workspace: 'ws', type: '' } as never), null); + // provider type with a valid source but no event-name suffix + assert.equal(envelopeToAgentEvent({ id: 'x', workspace: 'ws', type: 'github.' } as never), null); +}); diff --git a/packages/runtime/src/to-agent-event.ts b/packages/runtime/src/to-agent-event.ts new file mode 100644 index 00000000..425ecf01 --- /dev/null +++ b/packages/runtime/src/to-agent-event.ts @@ -0,0 +1,178 @@ +import { + createAgentEvent, + createCronTickEvent, + createStartupEvent, + type AgentEvent, + type EventResource, + type EventSummary, + type EventType +} from '@agent-relay/events'; +import type { RawGatewayEnvelope } from './shim.js'; + +/** + * Map a cloud proactive-runtime gateway envelope ({@link RawGatewayEnvelope}) + * into the relay SDK's normalized {@link AgentEvent}. + * + * This is the kernel of the runtime's planned migration onto `@agent-relay/ + * events` (see `raw.ts`/`shim.ts`): it proves the gateway wire contract can be + * expressed in the published SDK's event model using only published + * constructors — no upstream change required. It is intentionally **additive** + * — `shimEnvelope()` (which produces the legacy `WorkforceEvent` consumed by + * persona handlers today) is unchanged. Switching the dispatch path and the + * public handler signature over to `AgentEvent` is a separate, breaking stage. + * + * Returns `null` for envelope shapes the mapper can't normalize, mirroring + * `shimEnvelope`'s drop-and-ack behavior so a newer gateway can't crash-loop + * the runner. + * + * Fidelity note: cloud delivers the provider payload in `env.resource`, while + * the SDK exposes payloads through `expand('full')`. We wire a `loadFull` + * loader so handlers can still reach the payload, but the richer summary/diff/ + * thread expansions the SDK models are only as detailed as cloud's envelope — + * closing that gap is a cloud-gateway enrichment, not an SDK change. + */ +export function envelopeToAgentEvent(env: RawGatewayEnvelope): AgentEvent | null { + if (typeof env.id !== 'string' || !env.id) return null; + if (typeof env.workspace !== 'string' || !env.workspace) return null; + if (typeof env.type !== 'string' || !env.type) return null; + + const attempt = typeof env.attempt === 'number' && env.attempt > 0 ? env.attempt : 1; + const occurredAt = typeof env.occurredAt === 'string' ? env.occurredAt : undefined; + const digest = typeof env.digest === 'string' ? env.digest : undefined; + const summary = isPlainObject(env.summary) ? (env.summary as EventSummary) : undefined; + + if (env.type === 'cron.tick' || env.type.startsWith('cron.')) { + return createCronTickEvent({ + workspace: env.workspace, + // The SDK's cron event carries the expression as `schedule`; the human + // schedule name (cloud's `env.name`) becomes the synthetic resource id. + schedule: typeof env.cron === 'string' && env.cron ? env.cron : (env.name ?? ''), + scheduleId: typeof env.name === 'string' && env.name ? env.name : undefined, + id: env.id, + attempt, + occurredAt, + digest, + summary + }); + } + + if (env.type === 'startup') { + return createStartupEvent({ + workspace: env.workspace, + id: env.id, + attempt, + occurredAt, + digest, + summary + }); + } + + // Relaycast inbox messages. Two cloud delivery shapes converge here: + // - `relaycast.message` — a fresh inbox trigger. The relay-native gateway + // (`agent-gateway/envelope-builder.ts`) emits `channel`/`messageId`/ + // `threadId` as top-level fields, but the proactive-runtime HTTP gateway + // (`deployment-trigger-delivery.ts`, which `RawGatewayEnvelope` mirrors) + // does not carry them in `ENVELOPE_FIELDS` yet — they ride inside + // `resource` (`resource: payload.resource ?? payload`). We read from + // `resource` so this works today; promoting them to first-class envelope + // fields is the cross-repo follow-up the cloud contract test anticipates. + // - `user_reply` — cloud's continuation resume when a human answers a + // thread the agent paused on. Semantically a relaycast message; the + // thread id rides in `resumeContext`. + if (env.type === 'relaycast.message' || env.type === 'user_reply') { + const res = isPlainObject(env.resource) ? env.resource : {}; + const message = isPlainObject(res.message) ? res.message : undefined; + const resume = isPlainObject(env.resumeContext) ? env.resumeContext : {}; + // Prefer the first-class envelope fields (cloud now surfaces these top-level + // per Unit B / workforce#189); fall back to `resource` for envelopes from an + // older gateway that still nests them, then to the message/resume shapes. + const channel = firstString(env.channel) ?? firstString(res.channel) ?? 'dm'; + const messageId = + firstString(env.messageId) ?? + firstString(res.messageId) ?? + firstString(message?.id) ?? + (typeof env.deliveryId === 'string' && env.deliveryId ? env.deliveryId : env.id); + const threadId = + firstString(env.threadId) ?? firstString(res.threadId) ?? firstString(resume.threadId); + const text = firstString(res.text) ?? firstString(message?.text); + const path = firstString(res.path) ?? `/relaycast/${channel}/messages/${messageId}`; + return createAgentEvent( + { + workspace: env.workspace, + type: 'relaycast.message', + id: env.id, + attempt, + occurredAt, + resource: { path, kind: 'relaycast.message', id: messageId, provider: 'relaycast' }, + summary: summary ?? (text ? { title: text.slice(0, 120) } : undefined), + digest, + channel, + messageId, + ...(threadId ? { threadId } : {}) + }, + isPlainObject(env.resource) + ? { loadFull: async () => ({ level: 'full' as const, path, data: env.resource as Record, digest }) } + : undefined + ); + } + + // Provider envelopes are typed `.` — e.g. + // `github.pull_request.opened`, `linear.issue.created`. + const firstDot = env.type.indexOf('.'); + if (firstDot <= 0) return null; + const suffix = env.type.slice(firstDot + 1); + if (!suffix) return null; + const provider = + typeof env.provider === 'string' && env.provider ? env.provider : env.type.slice(0, firstDot); + + const path = Array.isArray(env.paths) && typeof env.paths[0] === 'string' ? env.paths[0] : ''; + const resource: EventResource = { + path, + // `kind` is the provider-scoped object type, dropping the trailing action: + // `github.pull_request.opened` -> `github.pull_request`. + kind: deriveKind(env.type), + id: typeof env.deliveryId === 'string' && env.deliveryId ? env.deliveryId : env.id, + provider + }; + + // Cloud delivers the provider payload in `env.resource`. The SDK's + // `expand('full')` returns a `FullExpansion` whose `data` is a record, so we + // only wire the loader when the payload is an object (the normal case); other + // shapes fall back to the SDK's default expander. + const payload = env.resource; + return createAgentEvent( + { + workspace: env.workspace, + type: env.type as EventType, + id: env.id, + attempt, + occurredAt, + resource, + summary, + digest + }, + isPlainObject(payload) + ? { loadFull: async () => ({ level: 'full' as const, path, data: payload, digest }) } + : undefined + ); +} + +/** + * Drop the trailing action segment from a provider event type to recover the + * resource kind: `github.pull_request.opened` -> `github.pull_request`. Types + * with only `.` (no action) are returned unchanged. + */ +function deriveKind(type: string): string { + const lastDot = type.lastIndexOf('.'); + const firstDot = type.indexOf('.'); + return lastDot > firstDot ? type.slice(0, lastDot) : type; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Return `value` when it is a non-empty string, else `undefined`. */ +function firstString(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} diff --git a/packages/runtime/src/trajectory.test.ts b/packages/runtime/src/trajectory.test.ts index 6323df76..a7ac6095 100644 --- a/packages/runtime/src/trajectory.test.ts +++ b/packages/runtime/src/trajectory.test.ts @@ -3,18 +3,18 @@ import assert from 'node:assert/strict'; import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { createCronTickEvent } from '@agent-relay/events'; import { createTrajectoryRecorder } from './trajectory.js'; import type { CompactedTrajectoryContract, WorkforceEvent } from './types.js'; -const cronEvent: WorkforceEvent = { - source: 'cron', +const cronEvent: WorkforceEvent = createCronTickEvent({ + workspace: 'ws-1', + schedule: '0 9 * * 6', + scheduleId: 'weekly', id: 'evt-1', - occurredAt: '2026-01-01T00:00:00.000Z', attempt: 1, - workspaceId: 'ws-1', - name: 'weekly', - cron: '0 9 * * 6' -}; + occurredAt: '2026-01-01T00:00:00.000Z' +}); const silentLog = () => {}; @@ -59,7 +59,7 @@ test('records a run and emits a contract-shaped artifact', async () => { assert.equal(contract.version, 1); assert.equal(contract.personaId, 'demo'); assert.equal(contract.projectId, 'ws-1'); // workspaceId fallback - assert.equal(contract.task.title, 'cron:weekly'); + assert.equal(contract.task.title, 'cron:0 9 * * 6'); assert.equal(contract.status, 'completed'); assert.equal(typeof contract.startedAt, 'string'); assert.equal(typeof contract.completedAt, 'string'); @@ -101,7 +101,7 @@ test('auto-finalizes on complete() when the handler did not call done()', async const contract = await readOnlyContract(root, 'demo'); assert.equal(contract.status, 'completed'); assert.ok(contract.retrospective, 'auto-finalize still emits a retrospective'); - assert.equal(contract.retrospective?.summary, 'Completed cron:weekly'); + assert.equal(contract.retrospective?.summary, 'Completed cron:0 9 * * 6'); }); }); diff --git a/packages/runtime/src/trajectory.ts b/packages/runtime/src/trajectory.ts index 3f680add..094829db 100644 --- a/packages/runtime/src/trajectory.ts +++ b/packages/runtime/src/trajectory.ts @@ -7,6 +7,7 @@ import { type TrajectorySession, type Trajectory } from 'agent-trajectories/sdk'; +import { isCronTickEvent } from '@agent-relay/events'; import type { CompactedTrajectoryContract, TrajectoryContext, @@ -15,6 +16,11 @@ import type { WorkforceEvent } from './types.js'; +/** Stable display label for an event's origin (provider slug, or `cron`). */ +function eventSourceLabel(event: WorkforceEvent): string { + return isCronTickEvent(event) ? 'cron' : event.resource.provider; +} + /** * Per-run trajectory recorder. Wraps the `agent-trajectories` SDK so the * runtime can auto-record a decision trajectory (the WHY) around every @@ -166,7 +172,7 @@ class ActiveTrajectoryRecorder implements TrajectoryRecorder { this.session = await this.client.start(title, { ...(description ? { description } : {}), workflowId: workflowIdFor(event), - tags: [`persona:${this.personaId}`, `workspace:${event.workspaceId}`, `source:${event.source}`] + tags: [`persona:${this.personaId}`, `workspace:${event.workspace}`, `source:${eventSourceLabel(event)}`] }); await this.session.chapter(`handle ${eventLabel(event)}`); } catch (err) { @@ -318,15 +324,19 @@ function normalizeAlternatives(alternatives: Decision['alternatives'] | undefine } function describeEvent(event: WorkforceEvent): { title: string; description?: string } { - if (event.source === 'cron') { - return { title: `cron:${event.name}`, description: `cron schedule "${event.name}" (${event.cron})` }; + if (isCronTickEvent(event)) { + return { + title: `cron:${event.schedule}`, + description: `cron schedule "${event.schedule}" fired for ${event.scheduledFor}` + }; } - const title = event.summary?.title?.trim() || `${event.source}:${event.type}`; - return { title, description: `${event.source} ${event.type} — event ${event.id}` }; + const source = eventSourceLabel(event); + const title = event.summary?.title?.trim() || `${source}:${event.type}`; + return { title, description: `${source} ${event.type} — event ${event.id}` }; } function eventLabel(event: WorkforceEvent): string { - return event.source === 'cron' ? 'cron:tick' : `${event.source}:${event.type}`; + return isCronTickEvent(event) ? 'cron:tick' : `${eventSourceLabel(event)}:${event.type}`; } function workflowIdFor(event: WorkforceEvent): string { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 524c650b..ed23004b 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -3,58 +3,40 @@ import type { PersonaSpec, PersonaMemoryScope } from '@agentworkforce/persona-kit'; +import type { AgentEvent, EventType } from '@agent-relay/events'; /** - * Source of an event delivered to a persona's `onEvent` handler. The - * runtime narrows the rest of the envelope based on this discriminator. + * v4: the event delivered to a persona handler IS the relay SDK's normalized + * {@link AgentEvent} (`@agent-relay/events`). The runtime decodes cloud's + * gateway envelope into it via `envelopeToAgentEvent`. Events discriminate on + * `event.type` (e.g. `cron.tick`, `github.pull_request.opened`, + * `relaycast.message`); the provider payload is reached via + * `await event.expand('full')`, and `event.resource` is the resource handle. * - * Sources today: `cron` (schedule tick), the Tier-1 Relayfile providers - * (`github`, `linear`, `slack`, `notion`, `jira`). Additional sources land - * as cloud proactive-runtime milestones M2/M3 ship. + * The pre-v4 `{ source, payload, workspaceId }` shape and its + * `WorkforceEventSource`/`WorkforceCronEvent`/`WorkforceProviderEvent` + * members are gone — see CHANGELOG and the migration notes in `define-agent`. */ -export type WorkforceEventSource = - | 'cron' - | 'github' - | 'linear' - | 'slack' - | 'notion' - | 'jira'; - -/** Common envelope fields every event carries, regardless of source. */ -interface WorkforceEventBase { - /** Stable, idempotency-safe identifier; the runtime dedupes on this. */ - id: string; - /** ISO timestamp the event fired at the source (not at delivery). */ - occurredAt: string; - /** Delivery attempt count, 1 for first delivery. */ - attempt: number; - /** Workspace this event is scoped to. */ - workspaceId: string; -} +export type { AgentEvent, EventType }; +export type { + CronTickEvent, + RelaycastMessageEvent, + RelayfileChangeEvent, + StartupEvent +} from '@agent-relay/events'; +export { + isCronTickEvent, + isRelaycastMessageEvent, + isRelayfileChangeEvent, + isStartupEvent +} from '@agent-relay/events'; -export interface WorkforceCronEvent extends WorkforceEventBase { - source: 'cron'; - /** Schedule name as declared in the persona's `schedules[].name`. */ - name: string; - /** The persona's resolved cron expression for the schedule. */ - cron: string; -} - -/** Provider-specific event payload — kept loose for v1. */ -export interface WorkforceProviderEvent extends WorkforceEventBase { - source: Exclude; - /** Provider-normalized event name (e.g. `pull_request.opened`). */ - type: string; - /** Raw provider payload, normalized by the Relayfile adapter. */ - payload: unknown; - /** Optional summary the gateway computed (M2). Missing on M1. */ - summary?: { - title?: string; - status?: string; - actor?: string; - [key: string]: unknown; - }; -} +/** + * @deprecated v4 alias for the relay SDK {@link AgentEvent}. Retained so + * internal runtime references keep compiling during the migration; new code + * should use `AgentEvent` directly. + */ +export type WorkforceEvent = AgentEvent; export function unwrapResourceRecord(payload: unknown): T | unknown { const resource = isRecord(payload) && 'resource' in payload ? payload.resource : payload; @@ -145,19 +127,18 @@ export type LinearAppUserNotificationEventPayload = notification?: LinearAppUserNotificationPayload['notification']; }; -export type LinearAgentSessionEvent = - | (Omit & { - source: 'linear'; - type: 'AgentSessionEvent.created' | 'AgentSessionEvent.prompted'; - payload: LinearAgentSessionEventPayload; - }) - | (Omit & { - source: 'linear'; - type: 'AppUserNotification.issueCommentMention'; - payload: LinearAppUserNotificationEventPayload; - }); - -export type WorkforceEvent = WorkforceCronEvent | LinearAgentSessionEvent | WorkforceProviderEvent; +/** + * Linear app-notification event types as cloud's gateway delivers them + * (provider-prefixed). The payload data (see {@link LinearAgentSessionEventPayload} + * / {@link LinearAppUserNotificationEventPayload}) is reached via + * `await event.expand('full')` — it is no longer a synchronous `event.payload`. + */ +export type LinearAgentSessionEventType = + | 'linear.AgentSessionEvent.created' + | 'linear.AgentSessionEvent.prompted' + | 'linear.AppUserNotification.issueCommentMention'; + +export type LinearAgentSessionEvent = AgentEvent; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2da407c3..b8165b90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@agent-assistant/proactive': specifier: ^0.4.32 version: 0.4.32 + '@agent-relay/events': + specifier: ^6.3.3 + version: 6.3.6 '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit @@ -157,6 +160,26 @@ packages: '@agent-relay/config@6.0.19': resolution: {integrity: sha512-vARUMQm5066xyqSJgG1IgpS/4DcogOz/4Qjf5cgo/rs+F/f0R1D5YgvuBNKtmkqIQ9FZ3m6hwsK2ZM0kQyrQMQ==} + '@agent-relay/events@6.3.6': + resolution: {integrity: sha512-3el7/iImhQ/L+TKetELF8SY6MEebmpxI8hbJ8qX1GWNyy46aXhuM/SwCMSSKch+Wmk6w9kitmx8csCGuF+4iNA==} + peerDependencies: + '@relayfile/adapter-github': ^0.2.5 + '@relayfile/adapter-jira': ^0.2.8 + '@relayfile/adapter-linear': ^0.2.5 + '@relayfile/adapter-notion': ^0.2.7 + '@relayfile/adapter-slack': ^0.2.7 + peerDependenciesMeta: + '@relayfile/adapter-github': + optional: true + '@relayfile/adapter-jira': + optional: true + '@relayfile/adapter-linear': + optional: true + '@relayfile/adapter-notion': + optional: true + '@relayfile/adapter-slack': + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -549,6 +572,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.207.0': + resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.217.0': resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==} engines: {node: '>=8.0.0'} @@ -569,6 +596,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.7.1': resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -623,6 +656,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.207.0': + resolution: {integrity: sha512-HSRBzXHIC7C8UfPQdu15zEEoBGv0yWkhEwxqgPCHVUKUQ9NLHVGXkVrf65Uaj7UwmAkC1gQfkuVYvLlD//AnUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.217.0': resolution: {integrity: sha512-38YQoqtYjglz2GV94LGUN/djLvxtvGIQO68o6qAFPVshjmwSdX1F2i0c7vn3lEl1L5B/YqjB/bgKXaVx7KO+RQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -653,6 +692,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.207.0': + resolution: {integrity: sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.217.0': resolution: {integrity: sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -665,6 +710,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.207.0': + resolution: {integrity: sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.217.0': resolution: {integrity: sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -683,18 +734,36 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.7.1': resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-logs@0.207.0': + resolution: {integrity: sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.217.0': resolution: {integrity: sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.7.1': resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -707,6 +776,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.7.1': resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -2057,6 +2132,17 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) + '@agent-relay/events@6.3.6': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.207.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -2768,6 +2854,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api-logs@0.207.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.217.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -2784,6 +2874,11 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -2869,6 +2964,15 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.207.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.207.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -2914,6 +3018,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/otlp-exporter-base@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.207.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base@0.217.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -2928,6 +3038,17 @@ snapshots: '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.207.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.207.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1) + protobufjs: 7.5.7 + '@opentelemetry/otlp-transformer@0.217.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -2949,12 +3070,25 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-logs@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.207.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs@0.217.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -2963,6 +3097,12 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -3000,6 +3140,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1