diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index a11ab43..fe82a49 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -38,7 +38,7 @@ One file. One command. One contract. ### In -- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `sandbox`, `memory`, `traits`, `onEvent`. +- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `memory`, `onEvent`. - New package `@agentworkforce/runtime` — thin facade exposing `handler(...)` that wraps `agent({...})` from `@agent-relay/agent` (cloud proactive-runtime M1 SDK). - New package `@agentworkforce/deploy` — the deploy CLI logic; the existing `cli.ts` gets a `deploy` case that dispatches to it. - Daytona sandbox launcher used in the `--sandbox` run mode. @@ -74,11 +74,11 @@ All new fields are optional. A persona that does not set any of them continues t | `useSubscription` | `boolean` | optional | When `true`, inference uses the user's connected LLM subscription via `@agent-relay/cloud`'s provider link (no workforce-billed tokens). Triggers a `connectProvider` step at deploy time. | | `integrations` | `Record` | when persona has event triggers | Declares which Relayfile providers this agent needs and what events fire its handler. See §3.2. | | `schedules` | `Schedule[]` | when persona runs on cron | One or more cron triggers, registered with the runtime's `ctx.schedule.every(...)`. Each schedule has a `name` echoed back to the handler. See §3.3. | -| `sandbox` | `boolean \| SandboxConfig` | optional | `true` (default) means agent runs inside a Daytona sandbox. `false` means the runner process owns its own filesystem. Object form lets you tune env / timeout. See §3.4. | -| `memory` | `boolean \| MemoryConfig` | optional | Enables the agent-assistant memory subsystem. Scopes and TTL configurable. See §3.5. | -| `traits` | `Traits` | optional, **only meaningful for interactive agents** | Mirrors `@agent-assistant/traits`: voice, formality, proactivity, etc. Applied when the agent posts to a chat surface (Slack, Relaycast). Headless agents (paraglide-style "Linear issue → ship") may omit this. See §3.6. | +| `memory` | `boolean \| MemoryConfig` | optional | Enables the agent-assistant memory subsystem. Scopes and TTL configurable. See §3.4. | | `onEvent` | `string` | when `cloud: true` and any trigger declared | Path to a TS file (relative to the persona JSON) whose default export is the event handler. Sub-file references like `./agent.ts` and `./handlers/index.ts` are supported. See §4. | +`traits` and `sandbox` were removed from the persona spec in v1. Personality belongs in the persona's prompt/sidecar and the persona-personality-builder flow. Sandbox behavior is deploy-time runtime configuration: sandbox mode is on by default for deploys, with opt-out handled by deploy flags or runtime config rather than persona JSON. + ### 3.2 `integrations` shape ```jsonc @@ -119,26 +119,13 @@ The act of stacking integrations is just declaring multiple keys. The act of lin - `cron` is a standard 5-field expression. `tz` defaults to `UTC`. - Multiple schedules are allowed. The runtime registers each with `ctx.schedule.every(cron, { tz, payload: { name } })`. -### 3.4 `sandbox` shape - -```jsonc -"sandbox": true // default -"sandbox": { "enabled": true, "timeoutSeconds": 1800, "env": { "FOO": "bar" } } -"sandbox": false // run in the runner process's fs -``` - -- Image is **not** user-configurable in v1. Workforce picks a standard image (`node-22` baseline) for the default Daytona sandbox. We can add `image` later if a real demand surfaces; eliminating the field keeps the v1 contract small. -- `timeoutSeconds` caps a single handler invocation. Default 1800s. -- `env` adds env vars on top of the auto-injected secrets (Relayfile connection tokens, harness inference creds, etc.). -- When `sandbox: false`, the agent's `ctx.sandbox` still exists but points at the runner's own process — useful for `--dev` iteration, **not** what we recommend for production. - -### 3.5 `memory` shape +### 3.4 `memory` shape ```jsonc "memory": true // sensible defaults "memory": { "enabled": true, - "scopes": ["session", "user", "workspace"], + "scopes": ["workspace", "user", "global"], "ttlDays": 30, "autoPromote": true, "dedupMs": 300000 @@ -146,29 +133,11 @@ The act of stacking integrations is just declaring multiple keys. The act of lin ``` - Implementation: the runtime wires `@agent-assistant/memory` with the supermemory adapter (matching sage today). API key is pulled from workforce-managed env, not declared in the persona. -- `scopes` is the only field with real semantic weight: session-only memory is wiped per handler; user-scope persists across the user's invocations of this agent; workspace persists across all users. +- `scopes` is the only field with real semantic weight: workspace memory persists across users in a workspace, user memory follows an individual user's invocations, and global memory is shared across the deployed agent. - `autoPromote` flips on the sage turn-recorder pattern — agent decides if session content is worth promoting. -- **No `memoryMd` file.** Memory is config, not prose. Personality goes in `traits` and `description`. - -### 3.6 `traits` shape - -Direct mapping to `@agent-assistant/traits`: - -```jsonc -"traits": { - "voice": "professional-warm", - "formality": "low", - "proactivity": "medium", - "riskPosture": "conservative", - "domain": "engineering", - "vocabulary": ["PR", "diff", "CI"], - "preferMarkdown": true -} -``` - -Only used when the runtime renders into a conversational surface (Slack message, Relaycast post, GitHub PR comment). Skip the field entirely for headless agents — saves the runtime a subsystem registration. +- **No `memoryMd` file.** Memory is config, not prose. Personality goes in prompt/sidecar content and the persona-personality-builder flow. -### 3.7 Trigger-name registry +### 3.5 Trigger-name registry `packages/persona-kit/src/triggers.ts` (new) ships a small registry of known trigger names per provider so the deploy CLI can lint them: @@ -217,7 +186,7 @@ interface WorkforceCtx { notion?: NotionClient; jira?: JiraClient; - // Daytona sandbox (or process fs if sandbox:false) + // Daytona sandbox or runtime-provided process fs sandbox: { cwd: string; // absolute path inside the sandbox exec(cmd: string, opts?: { cwd?: string; env?: Record }): Promise; @@ -242,7 +211,7 @@ interface WorkforceCtx { cancel(name: string): Promise; }; - // Persona metadata (id, traits, harness tier defaults, etc.) — read-only + // Persona metadata (id, harness defaults, listeners, etc.) — read-only persona: PersonaSpec; } @@ -254,7 +223,7 @@ export function handler( Implementation notes: - `handler(...)` reads the persona JSON adjacent to the entrypoint (workforce bundles them together). At cold-start it: 1. Calls `agent({ workspace, schedule, watch, inbox, onEvent: shim })` from `@agent-relay/agent`, mapping `persona.integrations` to `watch` and `persona.schedules` to `schedule`. - 2. Builds `ctx` once per agent boot: opens Daytona handle (if `sandbox: true`), wires Relayfile-derived clients, attaches memory adapter. + 2. Builds `ctx` once per agent boot: opens Daytona handle when deploy runs in sandbox mode, wires Relayfile-derived clients, attaches memory adapter. 3. The `shim` reshapes the raw envelope from `@agent-relay/agent` into the `WorkforceEvent` discriminated union and invokes the user's `fn(ctx, event)`. - The user never imports `@agent-relay/agent` directly. Workforce owns the ergonomics. If the underlying SDK churns, we absorb the diff here. - The SDK doors stay open for power users: we re-export `agent` from `@agentworkforce/runtime/raw` so anyone who wants the lower-level surface can drop down. This matters for nightcto-shaped projects that outgrow the persona contract. @@ -354,7 +323,6 @@ Direct port of the proactive-agents weekly-digest pattern. "cloud": true, "integrations": { "github": { "scope": { "repo": "AgentWorkforce/weekly-digest" } } }, "schedules": [{ "name": "weekly", "cron": "0 9 * * 6", "tz": "UTC" }], - "sandbox": true, "memory": { "enabled": true, "scopes": ["workspace"], "ttlDays": 90 }, "onEvent": "./agent.ts", "tiers": { ... standard codex/opencode tiers ... } @@ -385,9 +353,7 @@ Direct port of the proactive-agents weekly-digest pattern. }, "slack": { "triggers": [{ "on": "app_mention" }] } }, - "sandbox": true, - "memory": { "enabled": true, "scopes": ["session", "workspace"] }, - "traits": { "voice": "professional-warm", "formality": "low", "preferMarkdown": true }, + "memory": { "enabled": true, "scopes": ["user", "workspace"] }, "onEvent": "./agent.ts", "tiers": { ... } } @@ -409,7 +375,7 @@ workforce/ │ ├── cli/ # add `deploy`, `login` cases │ ├── persona-kit/ # extend PersonaSpec schema (§3) │ │ └── src/ -│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Sandbox, +Memory, +Traits +│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Memory │ │ ├── parse.ts # extend parsePersonaSpec to read new fields │ │ └── triggers.ts # NEW — known triggers registry (§3.7) │ ├── harness-kit/ # no changes for v1 @@ -494,7 +460,7 @@ If a track slips, §10's fallback applies: ship `--dev` end-to-end with `weekly- Tasks that are mechanical, well-specified, and don't gate on my decisions — perfect for a codex agent spawned via `workforce agent code-implementer` or a similar persona: 1. **Trigger registry expansion** — fill out `packages/persona-kit/src/triggers.ts` with the full set of known trigger names per Tier-1 provider (Linear, GitHub, Slack, Notion, Jira) by reading the Relayfile provider docs in `/Users/khaliqgant/Projects/AgentWorkforce/relayfile/docs/`. -2. **Test fixtures** — generate sample `persona.json` files exercising every optional combination (with/without traits, sandbox false, multi-schedule, etc.) into `packages/persona-kit/src/__fixtures__/`. +2. **Test fixtures** — generate sample `persona.json` files exercising deploy optional combinations (memory, multi-schedule, integrations, etc.) into `packages/persona-kit/src/__fixtures__/`. 3. **JSON Schema export** — emit a JSON Schema from the extended `PersonaSpec` for editor autocomplete. New script: `packages/persona-kit/scripts/emit-schema.mjs`. Wire to `pnpm run build` so it ships with the package. 4. **Example expansion** — write a third example, `examples/linear-shipper/` (the paraglide pattern: Linear issue created → drive to PR), purely against the runtime substrate I land in §9.1. 5. **README polish** — once the deploy command is real, codex agent rewrites the workforce README to lead with the deploy story. diff --git a/examples/linear-shipper/README.md b/examples/linear-shipper/README.md new file mode 100644 index 0000000..02fe5d4 --- /dev/null +++ b/examples/linear-shipper/README.md @@ -0,0 +1,17 @@ +# Linear Shipper + +This deployable persona follows the paraglide pattern: a Linear issue triggers a sandboxed implementation run, then the agent links the result back to Linear. + +## Setup + +Connect Linear and GitHub before deploying. + +```bash +workforce deploy ./examples/linear-shipper/persona.json --mode dev +``` + +Set the target repository through the persona inputs: `GITHUB_OWNER`, `GITHUB_REPO`, and `REPO_URL`. + +## Current GitHub Handoff + +The v1 client contract exposes `createIssue`, not `createPr`, so the example creates a draft handoff issue and includes a `TODO(human)` where `createPr` should be used once the runtime exposes it. diff --git a/examples/linear-shipper/agent.ts b/examples/linear-shipper/agent.ts new file mode 100644 index 0000000..a465906 --- /dev/null +++ b/examples/linear-shipper/agent.ts @@ -0,0 +1,69 @@ +import { handler } from '@agentworkforce/runtime'; + +type LinearIssueEvent = { + issue?: { id?: string; identifier?: string; title?: string; url?: string }; +}; + +function inputDefault(ctx: Parameters[0]>[0], name: string): string { + // Mirror `resolvePersonaInputs` precedence (packages/persona-kit/src/inputs.ts): + // explicit env var (spec.env ?? input name) wins over the static JSON default. + const spec = ctx.persona.inputs?.[name]; + const envName = spec?.env ?? name; + const fromEnv = process.env[envName]; + const value = (fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined) ?? spec?.default; + if (!value) throw new Error(`${name} input is required`); + return value; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function safeRepoDirName(value: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error('GITHUB_REPO must be a repository name, not a path or shell fragment'); + } + return value; +} + +export default handler(async (ctx, event) => { + if (event.source !== 'linear' || event.type !== 'issue.created') return; + if (!ctx.linear) throw new Error('linear-shipper requires the linear integration'); + if (!ctx.github) throw new Error('linear-shipper requires the github integration'); + + const payload = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as LinearIssueEvent) + : {}; + const issueRef = payload.issue; + const issueId = issueRef?.id ?? issueRef?.identifier; + if (!issueId) throw new Error('Linear event is missing an issue id'); + + const issue = await ctx.linear.getIssue(issueId); + const repoUrl = inputDefault(ctx, 'REPO_URL'); + const owner = inputDefault(ctx, 'GITHUB_OWNER'); + const repo = safeRepoDirName(inputDefault(ctx, 'GITHUB_REPO')); + const repoDir = `${ctx.sandbox.cwd}/${repo}`; + + await ctx.sandbox.exec(`git clone ${shellQuote(repoUrl)} ${shellQuote(repoDir)}`); + const result = await ctx.harness.run({ + prompt: `Implement this Linear issue. Create the smallest reviewable change and include verification notes.\n\nTitle: ${issue.title}\n\n${issue.description ?? ''}`, + cwd: repoDir + }); + + // TODO(human): createPr is not in the published GithubClient contract yet. + const created = await ctx.github.createIssue({ + owner, + repo, + title: `Draft PR needed: ${issue.title}`, + body: [ + `Linear issue: ${issue.url ?? issueId}`, + '', + 'The harness produced an implementation attempt, but GithubClient.createPr is not exposed yet.', + '', + result.output + ].join('\n') + }); + + await ctx.linear.comment(issueId, `Implementation attempt captured in GitHub issue: ${created.url}`); +}); diff --git a/examples/linear-shipper/persona.json b/examples/linear-shipper/persona.json new file mode 100644 index 0000000..4e5156d --- /dev/null +++ b/examples/linear-shipper/persona.json @@ -0,0 +1,41 @@ +{ + "id": "linear-shipper", + "intent": "implement-frontend", + "tags": ["implementation"], + "description": "Turns a new Linear issue into an implementation attempt and links the resulting GitHub work back to Linear.", + "cloud": true, + "integrations": { + "linear": { + "triggers": [{ "on": "issue.created" }] + }, + "github": { + "scope": { + "repo": "AgentWorkforce/workforce" + } + } + }, + "inputs": { + "GITHUB_OWNER": { + "description": "GitHub owner containing the target repository.", + "default": "AgentWorkforce" + }, + "GITHUB_REPO": { + "description": "Target repository name.", + "default": "workforce" + }, + "REPO_URL": { + "description": "Clone URL for the target repository.", + "default": "https://github.com/AgentWorkforce/workforce.git" + } + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5.4", + "systemPrompt": "Implement Linear issues with small, reviewable changes and clear handoff notes.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 1200, + "sandboxMode": "workspace-write", + "workspaceWriteNetworkAccess": true + } +} diff --git a/examples/review-agent/README.md b/examples/review-agent/README.md new file mode 100644 index 0000000..c98790d --- /dev/null +++ b/examples/review-agent/README.md @@ -0,0 +1,23 @@ +# Review Agent + +This deployable persona listens for GitHub pull request events and Slack mentions, delegates code reasoning to the configured harness, and posts the result back through the connected integration. + +## Setup + +Connect GitHub and Slack before deploying. Because `useSubscription` is enabled, deployment also connects the model provider derived from the persona's `model` field. + +⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). + +```bash +workforce deploy ./examples/review-agent/persona.json --mode dev +``` + +## Events + +The persona handles opened pull requests, issue comment mentions, pull request review comments, failed check runs, and Slack app mentions. + +## Run + +```bash +workforce deploy ./examples/review-agent/persona.json --mode sandbox +``` diff --git a/examples/review-agent/agent.ts b/examples/review-agent/agent.ts new file mode 100644 index 0000000..84167c9 --- /dev/null +++ b/examples/review-agent/agent.ts @@ -0,0 +1,110 @@ +import { handler } from '@agentworkforce/runtime'; + +type GithubTarget = { owner: string; repo: string; number: number }; + +function payloadOf(eventPayload: unknown): Record { + return typeof eventPayload === 'object' && eventPayload !== null + ? (eventPayload as Record) + : {}; +} + +function githubTarget(event: Record): GithubTarget { + const repository = event.repository as { + owner?: string | { login?: string }; + name?: string; + full_name?: string; + } | undefined; + const pullRequest = event.pull_request as { number?: number } | undefined; + const issue = event.issue as { number?: number } | undefined; + const checkRun = event.check_run as { pull_requests?: Array<{ number?: number }> } | undefined; + const owner = typeof repository?.owner === 'string' + ? repository.owner + : repository?.owner?.login ?? repository?.full_name?.split('/')[0]; + const repo = repository?.name ?? repository?.full_name?.split('/')[1]; + const checkRunPullRequest = checkRun?.pull_requests?.find((pr) => typeof pr.number === 'number'); + const number = pullRequest?.number ?? issue?.number ?? checkRunPullRequest?.number ?? Number(event.number); + if (!owner || !repo || !Number.isFinite(number)) { + throw new Error('GitHub event is missing owner, repo, or number'); + } + return { owner, repo, number }; +} + +async function reviewPullRequest(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const target = githubTarget(event); + const pr = await ctx.github.getPr(target); + const result = await ctx.harness.run({ + prompt: `Review this PR for correctness, risk, and missing tests.\n\nTitle: ${pr.title}\nAuthor: ${pr.author}\nBase: ${pr.base}\nHead: ${pr.head}\n\n${pr.diff}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.postReview(target, { event: 'COMMENT', body: result.output }); +} + +async function replyToGithubMention(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const target = githubTarget(event); + const comment = event.comment as { body?: string } | undefined; + const result = await ctx.harness.run({ + prompt: `Reply to this GitHub discussion in context. Keep it specific and actionable.\n\n${comment?.body ?? ''}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.comment(target, result.output); +} + +async function handleFailedCheck(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const checkRun = event.check_run as { conclusion?: string; output?: { title?: string; summary?: string } } | undefined; + if (checkRun?.conclusion !== 'failure') return; + const target = githubTarget(event); + const result = await ctx.harness.run({ + prompt: `CI failed. Inspect the failure and propose the smallest safe fix.\n\n${checkRun.output?.title ?? ''}\n\n${checkRun.output?.summary ?? ''}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.comment(target, result.output); +} + +async function replyInSlack(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.slack) throw new Error('review-agent requires the slack integration'); + const text = typeof event.text === 'string' ? event.text : ''; + const channel = typeof event.channel === 'string' ? event.channel : ''; + const ts = typeof event.threadTs === 'string' + ? event.threadTs + : typeof event.thread_ts === 'string' + ? event.thread_ts + : typeof event.ts === 'string' + ? event.ts + : ''; + if (!channel || !ts) throw new Error('Slack app_mention event is missing channel or thread timestamp'); + const memories = await ctx.memory.recall(text, { limit: 5 }); + const result = await ctx.harness.run({ + prompt: `Answer this Slack mention using the remembered context when useful.\n\nContext:\n${JSON.stringify(memories)}\n\nMessage:\n${text}`, + cwd: ctx.sandbox.cwd + }); + await ctx.slack.reply({ channel, ts }, result.output); + await ctx.memory.save(`Slack mention handled: ${text.slice(0, 180)}`, { + tags: ['slack', 'review-agent'], + scope: 'workspace' + }); +} + +export default handler(async (ctx, event) => { + if (event.source === 'github') { + const payload = payloadOf(event.payload); + if (event.type === 'pull_request.opened') { + await reviewPullRequest(ctx, payload); + return; + } + if (event.type === 'issue_comment.created' || event.type === 'pull_request_review_comment.created') { + await replyToGithubMention(ctx, payload); + return; + } + if (event.type === 'check_run.completed') { + await handleFailedCheck(ctx, payload); + return; + } + } + + if (event.source === 'slack' && event.type === 'app_mention') { + await replyInSlack(ctx, payloadOf(event.payload)); + } +}); diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json new file mode 100644 index 0000000..4c69399 --- /dev/null +++ b/examples/review-agent/persona.json @@ -0,0 +1,35 @@ +{ + "id": "review-agent", + "intent": "review", + "tags": ["review"], + "description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.", + "cloud": true, + "useSubscription": true, + "integrations": { + "github": { + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "issue_comment.created", "match": "@mention" }, + { "on": "pull_request_review_comment.created", "match": "@mention" }, + { "on": "check_run.completed", "where": "conclusion=failure" } + ] + }, + "slack": { + "triggers": [{ "on": "app_mention" }] + } + }, + "memory": { + "enabled": true, + "scopes": ["workspace"] + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5.4", + "systemPrompt": "Review pull requests for correctness, regression risk, security concerns, and missing tests. Be concise and concrete.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 1200, + "sandboxMode": "workspace-write", + "workspaceWriteNetworkAccess": true + } +} diff --git a/examples/weekly-digest/persona.json b/examples/weekly-digest/persona.json index 8f028f7..a127b25 100644 --- a/examples/weekly-digest/persona.json +++ b/examples/weekly-digest/persona.json @@ -35,7 +35,6 @@ "tz": "UTC" } ], - "sandbox": true, "memory": { "enabled": true, "scopes": [ diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 56d7f47..02f6457 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -59,8 +59,6 @@ export const sandboxLauncher: ModeLauncher = { throw err; } - const sandboxTimeoutSeconds = resolveTimeoutSeconds(input.persona.sandbox); - let stopping = false; const stop = async (): Promise => { if (stopping) return; @@ -77,8 +75,7 @@ export const sandboxLauncher: ModeLauncher = { const done = (async () => { try { const result = await client.exec(handle, 'node runner.mjs', { - cwd: SANDBOX_BUNDLE_DIR, - timeoutSeconds: sandboxTimeoutSeconds + cwd: SANDBOX_BUNDLE_DIR }); const output = result.output.trim(); if (output.length > 0) input.io.info(`[sandbox] ${output}`); @@ -149,14 +146,6 @@ export function resolveSandboxClient( }); } -function resolveTimeoutSeconds(sandbox: ModeLaunchInput['persona']['sandbox']): number | undefined { - if (sandbox === undefined || sandbox === true || sandbox === false) return undefined; - if (typeof sandbox.timeoutSeconds === 'number' && sandbox.timeoutSeconds > 0) { - return sandbox.timeoutSeconds; - } - return undefined; -} - // Re-exported for tests + power users wanting to compose the client manually. export { SANDBOX_BUNDLE_DIR, diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 9730154..f2ca1e3 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -32,14 +32,11 @@ export type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode, SkillInstall, SkillMaterializationOptions, @@ -69,13 +66,11 @@ export { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, parseTags, - parseTraits, resolveSidecar, sidecarSelectionFields } from './parse.js'; diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e201c44..0d9768a 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { assertInputName, assertSidecarPath, INPUT_NAME_RE, + isIntent, parseHarnessSettings, parseIntegrations, parseInputs, @@ -13,13 +15,11 @@ import { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, - parseTags, - parseTraits + parseTags } from './parse.js'; function validSpec(over: Record = {}): Record { @@ -36,6 +36,15 @@ function validSpec(over: Record = {}): Record }; } +function parsePersonaFixture(path: string) { + const fixtureUrl = new URL(`../../../${path}`, import.meta.url); + const raw = JSON.parse(readFileSync(fixtureUrl, 'utf8')) as Record; + if (!isIntent(raw.intent)) { + throw new Error(`${path} declares an invalid intent`); + } + return parsePersonaSpec(raw, raw.intent); +} + test('parsePersonaSpec accepts a minimal valid flat spec', () => { const spec = parsePersonaSpec(validSpec(), 'documentation'); assert.equal(spec.id, 'p'); @@ -66,9 +75,7 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { } }, schedules: [{ name: 'weekly', cron: '0 9 * * 6', tz: 'UTC' }], - sandbox: { enabled: true, timeoutSeconds: 1800, env: { NODE_ENV: 'production' } }, memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, - traits: { voice: 'professional-warm', preferMarkdown: true }, onEvent: './agent.ts' }), 'documentation' @@ -77,16 +84,41 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { assert.equal(spec.cloud, true); assert.equal(spec.integrations?.github.triggers?.[0].on, 'pull_request.opened'); assert.equal(spec.schedules?.[0].name, 'weekly'); - assert.deepEqual(spec.sandbox, { - enabled: true, - timeoutSeconds: 1800, - env: { NODE_ENV: 'production' } - }); assert.deepEqual(spec.memory, { enabled: true, scopes: ['workspace'], ttlDays: 30 }); - assert.equal(spec.traits?.preferMarkdown, true); assert.equal(spec.onEvent, './agent.ts'); }); +test('parsePersonaSpec rejects removed deploy-v1 traits and sandbox keys', () => { + assert.throws( + () => parsePersonaSpec(validSpec({ traits: { voice: 'warm' } }), 'documentation'), + { + message: + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + } + ); + assert.throws( + () => parsePersonaSpec(validSpec({ sandbox: true }), 'documentation'), + { + message: + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + } + ); +}); + +test('parsePersonaSpec accepts the Relayfile-VFS example personas', () => { + const reviewAgent = parsePersonaFixture('examples/review-agent/persona.json'); + assert.equal(reviewAgent.id, 'review-agent'); + assert.equal(reviewAgent.intent, 'review'); + assert.equal(reviewAgent.integrations?.github.triggers?.length, 4); + assert.deepEqual(reviewAgent.memory, { enabled: true, scopes: ['workspace'] }); + + const linearShipper = parsePersonaFixture('examples/linear-shipper/persona.json'); + assert.equal(linearShipper.id, 'linear-shipper'); + assert.equal(linearShipper.intent, 'implement-frontend'); + assert.equal(linearShipper.integrations?.linear.triggers?.[0].on, 'issue.created'); + assert.equal(linearShipper.inputs?.GITHUB_OWNER.default, 'AgentWorkforce'); +}); + test('parsePersonaSpec throws when intent does not match the expected intent', () => { assert.throws( () => parsePersonaSpec(validSpec({ intent: 'review' }), 'documentation'), @@ -347,45 +379,24 @@ test('parsePersonaSpec rejects a non-object spec', () => { // --- deploy-v1 schema additions ---------------------------------------------- -test('parseSandbox accepts boolean shorthand and round-trips both forms', () => { - assert.equal(parseSandbox(true, 'sandbox'), true); - assert.equal(parseSandbox(false, 'sandbox'), false); - assert.equal(parseSandbox(undefined, 'sandbox'), undefined); - const obj = parseSandbox( - { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }, - 'sandbox' - ); - assert.deepEqual(obj, { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }); -}); - -test('parseSandbox rejects malformed objects with field-pointed errors', () => { - assert.throws(() => parseSandbox('on', 'sandbox'), /sandbox must be a boolean or an object/); - assert.throws( - () => parseSandbox({ enabled: 'yes' }, 'sandbox'), - /sandbox\.enabled must be a boolean/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: -1 }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: Number.POSITIVE_INFINITY }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); -}); - test('parseMemory accepts boolean + object forms and validates scopes', () => { assert.equal(parseMemory(true, 'memory'), true); assert.equal(parseMemory(false, 'memory'), false); assert.equal(parseMemory(undefined, 'memory'), undefined); const m = parseMemory( - { enabled: true, scopes: ['user', 'user', 'workspace'], ttlDays: 7, autoPromote: true, dedupMs: 0 }, + { + enabled: true, + scopes: ['user', 'user', 'workspace', 'global'], + ttlDays: 7, + autoPromote: true, + dedupMs: 0 + }, 'memory' ); // Duplicates are deduped while preserving first-seen order. assert.deepEqual(m, { enabled: true, - scopes: ['user', 'workspace'], + scopes: ['user', 'workspace', 'global'], ttlDays: 7, autoPromote: true, dedupMs: 0 @@ -395,47 +406,17 @@ test('parseMemory accepts boolean + object forms and validates scopes', () => { test('parseMemory rejects unknown scopes and non-positive ttl', () => { assert.throws( () => parseMemory({ scopes: ['planet'] }, 'memory'), - /memory\.scopes\[0\] must be one of: session, user, workspace, org, object/ + /memory\.scopes\[0\] must be one of: workspace, user, global/ + ); + assert.throws( + () => parseMemory({ scopes: ['session'] }, 'memory'), + /memory\.scopes\[0\] must be one of: workspace, user, global/ ); assert.throws(() => parseMemory({ scopes: [] }, 'memory'), /scopes must be a non-empty array/); assert.throws(() => parseMemory({ ttlDays: 0 }, 'memory'), /ttlDays must be a positive number/); assert.throws(() => parseMemory({ dedupMs: -1 }, 'memory'), /dedupMs must be a non-negative number/); }); -test('parseTraits keeps only supplied fields and validates enums', () => { - assert.equal(parseTraits(undefined, 'traits'), undefined); - assert.equal(parseTraits({}, 'traits'), undefined); // empty object collapses to undefined - const t = parseTraits( - { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }, - 'traits' - ); - assert.deepEqual(t, { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }); - assert.throws( - () => parseTraits({ formality: 'extreme' }, 'traits'), - /traits\.formality must be one of: low, medium, high/ - ); - assert.throws( - () => parseTraits({ riskPosture: 'wild' }, 'traits'), - /traits\.riskPosture must be one of: conservative, balanced, aggressive/ - ); -}); - test('parseSchedules validates cron, requires unique names, preserves tz when set', () => { const s = parseSchedules( [ @@ -568,11 +549,10 @@ test('parsePersonaSpec rejects non-boolean cloud / useSubscription', () => { ); }); -test('parsePersonaSpec keeps boolean shorthand sandbox / memory through round-trip', () => { +test('parsePersonaSpec keeps boolean shorthand memory through round-trip', () => { const spec = parsePersonaSpec( - validSpec({ cloud: true, sandbox: true, memory: false }), + validSpec({ cloud: true, memory: false }), 'documentation' ); - assert.equal(spec.sandbox, true); assert.equal(spec.memory, false); }); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index d5c1209..0a308c9 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -23,14 +23,11 @@ import type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode } from './types.js'; @@ -388,16 +385,11 @@ export function parseMcpServers( } const MEMORY_SCOPE_VALUES: readonly PersonaMemoryScope[] = [ - 'session', - 'user', 'workspace', - 'org', - 'object' + 'user', + 'global' ]; -const TRAIT_LEVEL_VALUES = ['low', 'medium', 'high'] as const; -const TRAIT_RISK_VALUES = ['conservative', 'balanced', 'aggressive'] as const; - const ONEVENT_EXT_RE = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/i; // Standard 5-field cron: minute hour day-of-month month day-of-week. Each @@ -572,39 +564,6 @@ export function parseSchedules( return out; } -export function parseSandbox(value: unknown, context: string): PersonaSandbox | 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, timeoutSeconds, env } = value; - const out: PersonaSandboxConfig = {}; - if (enabled !== undefined) { - if (typeof enabled !== 'boolean') { - throw new Error(`${context}.enabled must be a boolean if provided`); - } - out.enabled = enabled; - } - if (timeoutSeconds !== undefined) { - if ( - typeof timeoutSeconds !== 'number' || - !Number.isFinite(timeoutSeconds) || - timeoutSeconds <= 0 - ) { - throw new Error(`${context}.timeoutSeconds must be a positive number if provided`); - } - out.timeoutSeconds = timeoutSeconds; - } - if (env !== undefined) { - const parsedEnv = parseStringMap(env, `${context}.env`); - if (parsedEnv && Object.keys(parsedEnv).length > 0) { - out.env = parsedEnv; - } - } - return out; -} - export function parseMemory(value: unknown, context: string): PersonaMemory | undefined { if (value === undefined) return undefined; if (typeof value === 'boolean') return value; @@ -658,56 +617,6 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un return out; } -export function parseTraits(value: unknown, context: string): PersonaTraits | undefined { - if (value === undefined) return undefined; - if (!isObject(value)) { - throw new Error(`${context} must be an object if provided`); - } - const { voice, formality, proactivity, riskPosture, domain, vocabulary, preferMarkdown } = value; - const out: PersonaTraits = {}; - if (voice !== undefined) { - if (typeof voice !== 'string' || !voice.trim()) { - throw new Error(`${context}.voice must be a non-empty string if provided`); - } - out.voice = voice; - } - if (formality !== undefined) { - if (typeof formality !== 'string' || !TRAIT_LEVEL_VALUES.includes(formality as 'low')) { - throw new Error(`${context}.formality must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.formality = formality as PersonaTraits['formality']; - } - if (proactivity !== undefined) { - if (typeof proactivity !== 'string' || !TRAIT_LEVEL_VALUES.includes(proactivity as 'low')) { - throw new Error(`${context}.proactivity must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.proactivity = proactivity as PersonaTraits['proactivity']; - } - if (riskPosture !== undefined) { - if (typeof riskPosture !== 'string' || !TRAIT_RISK_VALUES.includes(riskPosture as 'balanced')) { - throw new Error(`${context}.riskPosture must be one of: ${TRAIT_RISK_VALUES.join(', ')}`); - } - out.riskPosture = riskPosture as PersonaTraits['riskPosture']; - } - if (domain !== undefined) { - if (typeof domain !== 'string' || !domain.trim()) { - throw new Error(`${context}.domain must be a non-empty string if provided`); - } - out.domain = domain; - } - if (vocabulary !== undefined) { - const parsed = parseStringList(vocabulary, `${context}.vocabulary`); - if (parsed) out.vocabulary = parsed; - } - if (preferMarkdown !== undefined) { - if (typeof preferMarkdown !== 'boolean') { - throw new Error(`${context}.preferMarkdown must be a boolean if provided`); - } - out.preferMarkdown = preferMarkdown; - } - return Object.keys(out).length > 0 ? out : undefined; -} - export function parseOnEvent(value: unknown, context: string): string | undefined { if (value === undefined) return undefined; return assertOnEventPath(value, context); @@ -717,6 +626,16 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): if (!isObject(value)) { throw new Error(`persona[${expectedIntent}] must be an object`); } + if ('traits' in value) { + throw new Error( + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + ); + } + if ('sandbox' in value) { + throw new Error( + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + ); + } const { id, @@ -743,9 +662,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): useSubscription, integrations, schedules, - sandbox, memory, - traits, onEvent } = value; @@ -826,9 +743,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): `persona[${expectedIntent}].integrations` ); const parsedSchedules = parseSchedules(schedules, `persona[${expectedIntent}].schedules`); - const parsedSandbox = parseSandbox(sandbox, `persona[${expectedIntent}].sandbox`); const parsedMemory = parseMemory(memory, `persona[${expectedIntent}].memory`); - const parsedTraits = parseTraits(traits, `persona[${expectedIntent}].traits`); const parsedOnEvent = parseOnEvent(onEvent, `persona[${expectedIntent}].onEvent`); return { @@ -856,9 +771,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): ...(typeof useSubscription === 'boolean' ? { useSubscription } : {}), ...(parsedIntegrations ? { integrations: parsedIntegrations } : {}), ...(parsedSchedules ? { schedules: parsedSchedules } : {}), - ...(parsedSandbox !== undefined ? { sandbox: parsedSandbox } : {}), ...(parsedMemory !== undefined ? { memory: parsedMemory } : {}), - ...(parsedTraits ? { traits: parsedTraits } : {}), ...(parsedOnEvent !== undefined ? { onEvent: parsedOnEvent } : {}) }; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index fff25a5..b231c82 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -153,12 +153,12 @@ export interface PersonaIntegrationTrigger { } /** - * Per-provider integration configuration. The map key is the Relayfile - * provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` - * is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` for - * github, `{ database: "" }` for notion). `triggers` are flat — all - * trigger events for this provider fan into the same `onEvent` handler, - * which discriminates on `event.source` + `event.type`. + * Radio listener configuration for a RelayFile provider. The map key is + * the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). + * `scope` is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` + * for github, `{ database: "" }` for notion). `triggers` are flat — + * all radio listener events for this provider fan into the same `onEvent` + * handler, which discriminates on `event.source` + `event.type`. */ export interface PersonaIntegrationConfig { scope?: Record; @@ -166,10 +166,10 @@ export interface PersonaIntegrationConfig { } /** - * A cron-style schedule. `name` is unique within the persona and surfaces - * to the handler as `event.name`. `cron` is a standard 5-field expression. - * `tz` defaults to `UTC` at the runtime layer (the parser keeps it - * optional so the spec stays close to what the author wrote). + * Clock listener configuration. `name` is unique within the persona and + * surfaces to the handler as `event.name`. `cron` is a standard 5-field + * expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps + * it optional so the spec stays close to what the author wrote). */ export interface PersonaSchedule { name: string; @@ -177,31 +177,8 @@ export interface PersonaSchedule { tz?: string; } -/** - * Long-form sandbox configuration. `enabled` defaults to true when the - * object form is present; supply the boolean shorthand `sandbox: false` - * to opt out entirely. `timeoutSeconds` caps a single handler invocation - * (default 1800s in the runtime). `env` is merged on top of auto-injected - * secrets at sandbox-create time. - * - * Image selection is intentionally not user-configurable in v1 — workforce - * picks a standard image. Add `image` later if a real demand surfaces. - */ -export interface PersonaSandboxConfig { - enabled?: boolean; - timeoutSeconds?: number; - env?: Record; -} - -/** - * Sandbox can be specified as `true` / `false` shorthand or as the full - * config object. The parser preserves whichever form the author wrote so - * round-trips stay lossless; consumers normalize when reading. - */ -export type PersonaSandbox = boolean | PersonaSandboxConfig; - /** Memory scope semantics, mirroring @agent-assistant/memory. */ -export type PersonaMemoryScope = 'session' | 'user' | 'workspace' | 'org' | 'object'; +export type PersonaMemoryScope = 'workspace' | 'user' | 'global'; /** * Long-form memory configuration. Defaults are applied by the runtime, @@ -219,21 +196,12 @@ export interface PersonaMemoryConfig { export type PersonaMemory = boolean | PersonaMemoryConfig; /** - * Conversational traits, applied only when the agent posts to a chat - * surface (Slack, Relaycast, GitHub PR comment). Headless agents — the - * paraglide "Linear issue → PR" pattern — should omit this field. Mirrors - * the trait shape in `@agent-assistant/traits`. + * A persona listens for events. Three listener kinds: clock (cron schedules + * through `schedules[]`), radio (RelayFile integration events through + * `integrations..triggers[]`), and inbox (RelayCast targeted + * messages, not yet modeled in v1). The current shape predates the + * listeners framing; semantics are equivalent. */ -export interface PersonaTraits { - voice?: string; - formality?: 'low' | 'medium' | 'high'; - proactivity?: 'low' | 'medium' | 'high'; - riskPosture?: 'conservative' | 'balanced' | 'aggressive'; - domain?: string; - vocabulary?: string[]; - preferMarkdown?: boolean; -} - export interface PersonaSpec { id: string; intent: string; @@ -333,25 +301,14 @@ export interface PersonaSpec { * for each provider not yet connected to the active workspace. */ integrations?: Record; - /** Cron-style schedules. Each `name` is unique within the persona. */ + /** Cron-style clock listeners. Each `name` is unique within the persona. */ schedules?: PersonaSchedule[]; - /** - * Sandbox preference. `true` (default for cloud personas) means the - * agent runs inside a Daytona sandbox at deploy time; `false` runs it in - * the runner process. The object form lets the author tune timeout / env. - */ - sandbox?: PersonaSandbox; /** * 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). */ memory?: PersonaMemory; - /** - * Conversational traits, applied only when the agent posts to a chat - * surface. Omit for headless agents. - */ - traits?: PersonaTraits; /** * 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/src/index.ts b/packages/runtime/src/index.ts index 154c425..395ef41 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -65,6 +65,5 @@ export type { PersonaIntegrationTrigger, PersonaMemoryScope, PersonaSchedule, - PersonaSpec, - PersonaTraits + PersonaSpec } from '@agentworkforce/persona-kit'; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 22580d5..113b9ea 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -177,7 +177,7 @@ export interface IntegrationClients { * integration fields undefined. */ export interface WorkforceCtx extends IntegrationClients { - /** Read-only persona metadata, useful for branching on traits. */ + /** Read-only persona metadata for handler decisions. */ readonly persona: PersonaSpec; /** Workspace the agent is deployed into. */ readonly workspaceId: string;