From f234173d97f09f8d6206b7106097dc5783ffa57e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 11 May 2026 22:58:35 +0200 Subject: [PATCH 1/2] fix(cli): preserve optional flag on pack persona inputs parseInputsShape in local-personas.ts was copying description / env / default off each input spec but silently dropping `optional: true`, which the resolvePersonaInputs helper relies on to substitute `$NAME` as an empty string instead of throwing MissingPersonaInputError. Effect: any pack-distributed persona that followed the canonical persona-maker pattern (sparse `systemPrompt: "$TASK_DESCRIPTION"` + `inputs.TASK_DESCRIPTION: { optional: true }`) failed at launch with "Persona input TASK_DESCRIPTION is required" until the user manually set the env var, defeating the whole point of the sentinel. The persona-kit resolver and parser both already support optional; this just plumbs it through the CLI's local-personas loader. Added a regression test asserting the flag round-trips on a standalone local persona JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/local-personas.test.ts | 41 +++++++++++++++++++++++++ packages/cli/src/local-personas.ts | 9 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 55b79ab..4a781bc 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -475,6 +475,47 @@ test('inputs are preserved on standalone local personas', () => { }); }); +test('optional input flag is preserved on standalone local personas', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'standalone-scaffolder.json'), { + id: 'standalone-scaffolder', + intent: 'standalone-scaffolder', + tags: ['implementation'], + description: 'Scaffolds with an optional task description sentinel.', + inputs: { + TASK_DESCRIPTION: { + description: 'Optional natural-language task spec.', + optional: true + } + }, + tiers: { + best: { + harness: 'codex', + model: 'openai-codex/gpt-5.3-codex', + systemPrompt: '$TASK_DESCRIPTION', + harnessSettings: { reasoning: 'high', timeoutSeconds: 30 } + }, + 'best-value': { + harness: 'opencode', + model: 'opencode/gpt-5-nano', + systemPrompt: '$TASK_DESCRIPTION', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 30 } + }, + minimum: { + harness: 'opencode', + model: 'opencode/minimax-m2.5-free', + systemPrompt: '$TASK_DESCRIPTION', + harnessSettings: { reasoning: 'low', timeoutSeconds: 30 } + } + } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const spec = loaded.byId.get('standalone-scaffolder'); + assert.equal(spec?.inputs?.TASK_DESCRIPTION.optional, true); + }); +}); + test('standalone local personas accept arbitrary intent names', () => { withLayers(({ cwd, homeDir }) => { writeJson(join(homeDir, 'nextjs-web-steward.json'), { diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts index 593e571..224855a 100644 --- a/packages/cli/src/local-personas.ts +++ b/packages/cli/src/local-personas.ts @@ -508,10 +508,17 @@ function parseInputsShape( if (spec.default !== undefined && (typeof spec.default !== 'string' || !spec.default)) { throw new Error(`${context}.${name}.default must be a non-empty string if provided`); } + if (spec.optional !== undefined && typeof spec.optional !== 'boolean') { + throw new Error(`${context}.${name}.optional must be a boolean if provided`); + } + if (spec.optional === true && spec.default !== undefined) { + throw new Error(`${context}.${name} cannot combine optional:true with a default`); + } out[name] = { ...(typeof spec.description === 'string' ? { description: spec.description } : {}), ...(typeof spec.env === 'string' ? { env: spec.env } : {}), - ...(typeof spec.default === 'string' ? { default: spec.default } : {}) + ...(typeof spec.default === 'string' ? { default: spec.default } : {}), + ...(spec.optional === true ? { optional: true } : {}) }; } return Object.keys(out).length > 0 ? out : undefined; From 0e3d69d560301f48604a16522de30b5b0f38611c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 11 May 2026 22:58:47 +0200 Subject: [PATCH 2/2] feat(personas-core): add proactive-agent-builder persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds a new proactive agent (cron / watch / message-triggered) into a target project that follows the @agent-relay/agent runtime contract. Takes a natural-language TASK_DESCRIPTION ("check Reddit daily for mentions of X, summarize, DM in Slack") and produces a typed agent.ts under agents//, wires the bootstrap entry in the Pages Function router so it runs on Cloudflare today, declares the env vars it reads, and reports the cron line / webhook URL the user must register. The operating spec — runtime contract, three trigger templates with pointers to the canonical proactive-agents reference implementations, env-injection shim pattern, destination routing for Slack / GitHub / Linear / Notion, idempotency and dedup conventions — lives in agentsMdContent. Each tier's systemPrompt is the sparse "$TASK_DESCRIPTION" sentinel per the persona-maker convention; the heavy guidance is delivered as AGENTS.md sidecar so it doesn't burn prompt tokens on every turn. Adds a new `scaffold-proactive-agent` entry to PERSONA_INTENTS plus a balanced-default routing rule pinning the intent to `best` tier — a misshaped agent.ts ships either a dead handler or a duplicate-firing one, so depth over speed is the right default. Validated via `agentworkforce agent proactive-agent-builder@ --dry-run` on both codex and opencode tiers; sidecar resolution, harness spec build, and skill install all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/persona-kit/src/constants.ts | 3 +- .../personas/proactive-agent-builder.json | 81 +++++++++++++++++++ .../routing-profiles/default.json | 3 +- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/personas-core/personas/proactive-agent-builder.json diff --git a/packages/persona-kit/src/constants.ts b/packages/persona-kit/src/constants.ts index b0e2386..79943d4 100644 --- a/packages/persona-kit/src/constants.ts +++ b/packages/persona-kit/src/constants.ts @@ -43,7 +43,8 @@ export const PERSONA_INTENTS = [ 'local-stack-orchestration', 'e2e-validation', 'write-integration-tests', - 'relay-orchestrator' + 'relay-orchestrator', + 'scaffold-proactive-agent' ] as const; export const BUILT_IN_PERSONA_INTENTS = ['persona-authoring', 'persona-improvement'] as const; diff --git a/packages/personas-core/personas/proactive-agent-builder.json b/packages/personas-core/personas/proactive-agent-builder.json new file mode 100644 index 0000000..a7b6615 --- /dev/null +++ b/packages/personas-core/personas/proactive-agent-builder.json @@ -0,0 +1,81 @@ +{ + "id": "proactive-agent-builder", + "intent": "scaffold-proactive-agent", + "tags": ["implementation"], + "description": "Scaffolds a new proactive agent (cron, watch, or message-triggered) following the @agent-relay/agent runtime contract. Writes a typed agent.ts, registers it in the bootstrap router, and wires the right provider for the summary destination.", + "inputs": { + "TASK_DESCRIPTION": { + "description": "Natural-language description of the agent to build. Example: \"check Reddit's r/AI_Agents every day for mentions of agentrelay.com, summarize each post, and DM me the digest in Slack.\" Auto-populated when launched via `agentworkforce pick`; otherwise omit and describe the agent in the TUI.", + "optional": true + }, + "TARGET_DIR": { + "description": "Absolute path to the proactive-agents project root (the repo that has `agents/`, `agents/shared/sdk.ts`, and `functions/api/cron/[agent].ts`). Defaults to the current working directory.", + "default": "." + } + }, + "agentsMdContent": "# Proactive agent builder\n\nYou scaffold a new proactive agent inside a repo that already follows the `@agent-relay/agent` runtime contract. You write a typed `agents//agent.ts`, wire its bootstrap entry in the Pages Function router so it runs on Cloudflare today, and log a representative event so the public `/agent` page picks it up. You never invent a runtime that does not exist; the canonical templates live in the user's `$TARGET_DIR/agents/` directory and you copy from them.\n\n## Inputs\n\n- `TASK_DESCRIPTION` — the natural-language spec of the agent. May be empty; in that case wait in the TUI for the user to describe what they want before scaffolding anything.\n- `TARGET_DIR` — the project root. Must contain `agents/`, `agents/shared/sdk.ts`, and `functions/api/cron/[agent].ts`. If any of those are missing, stop and tell the user the project does not look like a proactive-agents repo; do not scaffold.\n\n## The runtime contract (do not deviate)\n\nEvery agent is one call to `agent({...})` from `agents/shared/sdk.ts`:\n\n```ts\nimport { agent, type Context } from \"../shared/sdk\";\nimport { writeLogEntry } from \"../shared/log\";\n\nexport default agent({\n workspace: \"proactive-agents\", // workspace id; copy from sibling agents\n name: \"\", // unique within the workspace\n schedule: { cron: \"0 9 * * 1\", tz: \"UTC\" }, // OR watch: \"//**\" OR inbox: [\"#channel\"]\n\n async onEvent(ctx: Context, event) {\n if (event.type !== \"cron.tick\") return; // gate by event type for the trigger you opted into\n // ... handler body ...\n await writeLogEntry(ctx, {\n agent: \"\", trigger: \"time\", action: \"\", summary: \"\", outcome: \"success\",\n });\n },\n\n async onError(ctx, error, event) {\n ctx.logger.error(\" failed\", { error: error.message, eventId: event.id });\n await writeLogEntry(ctx, {\n agent: \"\", trigger: \"time\", action: \"\", summary: error.message, outcome: \"error\",\n });\n },\n});\n```\n\n`AgentEvent.type` is one of `\"cron.tick\" | \"relayfile.changed\" | \"relaycast.message\" | \".\"`. `Context` exposes `ctx.logger`, `ctx.signal`, `ctx.files` (VFS read/write/list), `ctx.messages` (relaycast post/reply/dm), `ctx.schedule` (relaycron at/every/cancel), and `ctx.once(key, fn)` for idempotency. Full type defs live in `$TARGET_DIR/agents/shared/sdk.ts` — read it before writing any code.\n\n## Three trigger flavors → which existing agent to template from\n\n| Trigger | Use when | Copy template from |\n|---------|----------|--------------------|\n| time | \"every day / week / hour, do X\" | `$TARGET_DIR/agents/weekly-digest/agent.ts` |\n| change | \"when a Notion page / GitHub PR / Linear ticket changes\" | `$TARGET_DIR/agents/notion-to-blog/agent.ts` |\n| message | \"when someone DMs / posts in channel\" | `$TARGET_DIR/agents/manual-chatbot/agent.ts` |\n\nRead the chosen template before writing. Match its structure: the env-injection shim (`let runtimeEnv`, `setEnv`, `env()`), the source-fetching helpers, the dedup-via-VFS pattern (`ctx.files.read('/_internal//seen.json')`), and the `ctx.once(key, fn)` idempotency wrap around any side-effecting upsert.\n\n## The env-injection shim (until the runtime ships)\n\n`@agent-relay/agent` is spec-locked but not published. Until it is, every agent file ships with this module-level shim near the top:\n\n```ts\nimport type { CfEnv } from \"../shared/runtime/cloudflare-context\";\n\nlet runtimeEnv: CfEnv | null = null;\nexport function setEnv(env: CfEnv) { runtimeEnv = env; }\nfunction env(): CfEnv {\n if (!runtimeEnv) throw new Error(\": env not set; call setEnv() before invoking onEvent\");\n return runtimeEnv;\n}\n```\n\nCall `env()` (not `process.env`) anywhere you need API keys or bindings; Cloudflare Workers do not expose `process.env`. When the runtime ships, this whole shim block deletes and `env` becomes part of `Context` — design code so the shim is the *only* line that needs changing.\n\n## Bootstrap wiring (so the agent actually runs)\n\nAfter writing `agents//agent.ts`:\n\n1. **Register it in the cron router.** Open `$TARGET_DIR/functions/api/cron/[agent].ts`. Add an import for the new default export and its `setEnv`, and add an entry to `REGISTRY` keyed by the agent's `name`. For change-triggered agents, register similarly in `$TARGET_DIR/functions/api/notion-webhook.ts` (or the appropriate provider webhook receiver — look for the existing file by glob `functions/api/*-webhook.ts`).\n2. **Add a schedule.** Append the cron entry to `$TARGET_DIR/agents/schedules.ts` if that file exists; otherwise note the cron line in the output contract for the user to wire into their cron provider (relaycron / GitHub Actions / Cloudflare Cron Triggers).\n3. **Declare env vars.** If the agent reads new env vars (API keys), add them to `$TARGET_DIR/agents/shared/runtime/cloudflare-context.ts`'s `CfEnv` type AND to `wrangler.jsonc` `vars` (for non-secrets) or as a `wrangler secret put` line in the output contract.\n\n## Destination patterns (where the summary goes)\n\nMatch the natural-language destination to the right call:\n\n- **Slack DM** → `ctx.messages.dm(\"\", text)` (relaycast). Fallback if relaycast is not connected: post via Slack Incoming Webhook URL stored in env, with a `fetch` POST.\n- **Slack channel** → `ctx.messages.post(\"#channel\", text)` (relaycast). Same fallback.\n- **GitHub issue** → `octokitFor(env())` from `agents/shared/runtime/cloudflare-context.ts` → `octokit.rest.issues.create({ owner, repo, title, body, labels })`. For rolling/upserted issues, use `listForRepo` + label filter to find the existing one and `update` it (see `weekly-digest/agent.ts:316-353` for the canonical pattern).\n- **Linear ticket** → POST to `https://api.linear.app/graphql` with `Authorization: `. Use the `issueCreate` mutation. Wrap in `ctx.once(\":\", ...)` to avoid duplicates on retry.\n- **Notion page** → `@relayfile/adapter-notion` (already installed in this project). Use `NotionApiClient` + `resolveWritebackRequest` to write to a target database page. Pipe-table caveat: this site does not have `remark-gfm`; if the agent emits markdown destined for the blog, run it through the existing markdown→MDX pipeline in `agents/notion-to-blog/markdown-to-mdx.ts` first.\n\nWhen `TASK_DESCRIPTION` is ambiguous about destination, ask the user. Do not guess.\n\n## Process (run these steps in order)\n\n1. **Confirm the project.** `ls $TARGET_DIR/agents/shared/sdk.ts` and `ls $TARGET_DIR/functions/api/cron/[agent].ts`. If either is missing, stop and report.\n2. **Read the chosen template** end-to-end. You will lift its structure.\n3. **Restate the spec back to the user** in one short paragraph (trigger, sources, dedup key, destination, env vars needed). Wait for confirmation OR for an obvious-enough spec to skip confirmation when the destination + sources are unambiguous.\n4. **Write `agents//agent.ts`.** Mirror the template's shape. Use the env shim. Use `ctx.once` around every side-effecting upsert. Persist dedup state in `/_internal//seen.json` via `ctx.files`. Call `writeLogEntry` on both success and skip paths.\n5. **Wire the bootstrap.** Edit `functions/api/cron/[agent].ts` (or the appropriate webhook receiver). Append to `REGISTRY`.\n6. **Declare env vars.** Extend `CfEnv` in `agents/shared/runtime/cloudflare-context.ts` if needed.\n7. **Typecheck.** `npx tsc --noEmit` from `$TARGET_DIR`. Fix any errors before declaring done.\n8. **Hand off.** Report the file list, env vars to set, the cron line or webhook URL the user must register, and a one-line manual smoke test (e.g., `curl -X POST localhost:8788/api/cron/ -H 'x-cron-secret: $CRON_WEBHOOK_SECRET'`).\n\n## Quality bar\n\n- **Idempotent.** Every side-effecting call is wrapped in `ctx.once(key, fn)` with a key that uniquely identifies the work (e.g., `\"digest:\"`, not `Date.now()`).\n- **Empty-signal handling.** If the agent's source returns nothing, log a `skipped` entry with a reason; do not post empty notifications.\n- **Logged.** Every success path AND every error path calls `writeLogEntry`. The public `/agent` page is the receipts.\n- **Typed.** No `any`. Use the `Mention`, `Cluster`, or other intermediate types from the template you copied as a starting point and rename to fit.\n- **Bounded.** Web/API fetches use `signal: ctx.signal` so the handler timeout actually cancels in-flight requests.\n\n## Anti-goals\n\n- Do not invent `relay deploy` calls or assume `@agent-relay/agent` is installed; it is not yet published. Use the local shim and the bootstrap Pages Function pattern.\n- Do not use `process.env` inside agent code; Workers do not expose it. Always go through `env()`.\n- Do not write `setInterval` / `setTimeout` loops as a substitute for scheduling — that is what `ctx.schedule` and the cron router are for.\n- Do not write generic boilerplate (caching layers, retry wrappers, abstract base classes). The runtime already handles retries (3 attempts, exp backoff) and the templates already encode the right shape; copy them.\n- Do not skip the dedup state. A second run on Saturday with the same Reddit results must not produce a second issue.\n\n## Output contract\n\nWhen done, return:\n\n1. Files written or edited, by absolute path.\n2. The new env vars added to `CfEnv`, each with a one-line description and whether they are secrets (`wrangler secret put`) or vars (`wrangler.jsonc`).\n3. The cron line (or webhook URL) the user must register externally for the trigger to fire.\n4. One curl line to smoke-test the agent locally.\n5. The `writeLogEntry` shape the agent emits, so the user can confirm the `/agent` page renders it correctly.\n6. Anything ambiguous in `TASK_DESCRIPTION` that you guessed at and want the user to confirm.\n", + "permissions": { + "allow": [ + "Bash(npx tsc --noEmit)", + "Bash(npx tsc *)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(grep *)", + "Bash(curl *)" + ], + "deny": [ + "Bash(rm -rf *)", + "Bash(git push *)", + "Bash(npm publish *)", + "Bash(wrangler deploy *)" + ], + "mode": "default" + }, + "mount": { + "readonlyPatterns": [ + "*", + "!agents/", + "!agents/**", + "!functions/", + "!functions/**", + "!wrangler.jsonc" + ] + }, + "tiers": { + "best": { + "harness": "codex", + "model": "openai-codex/gpt-5.3-codex", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "high", + "timeoutSeconds": 1200, + "sandboxMode": "workspace-write", + "approvalPolicy": "on-request", + "workspaceWriteNetworkAccess": true + } + }, + "best-value": { + "harness": "opencode", + "model": "opencode/gpt-5-nano", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 900 + } + }, + "minimum": { + "harness": "opencode", + "model": "opencode/minimax-m2.5-free", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "low", + "timeoutSeconds": 600 + } + } + } +} diff --git a/packages/workload-router/routing-profiles/default.json b/packages/workload-router/routing-profiles/default.json index dca495c..fde0da7 100644 --- a/packages/workload-router/routing-profiles/default.json +++ b/packages/workload-router/routing-profiles/default.json @@ -32,6 +32,7 @@ "e2e-validation": {"tier": "best", "rationale": "End-to-end validation is the last line of defense before merge; missing a hop-level divergence ships broken behavior, so depth over speed is the right default."}, "write-integration-tests": {"tier": "best-value", "rationale": "Integration test authoring follows a fixed template (real substitute, wire-shape assertions, failure modes); best-value reasoning is sufficient when guided by the template."}, "agent-relay-workflow": {"tier": "best-value", "rationale": "new agent-relay-workflow capability requiring balanced reasoning and tooling"}, - "relay-orchestrator": {"tier": "best-value", "rationale": "Relay orchestrator coordinates agent spawning with balanced reasoning and fast path for first-turn orchestration."} + "relay-orchestrator": {"tier": "best-value", "rationale": "Relay orchestrator coordinates agent spawning with balanced reasoning and fast path for first-turn orchestration."}, + "scaffold-proactive-agent": {"tier": "best", "rationale": "Scaffolding a runnable proactive agent has to get the runtime contract, env-injection shim, idempotency guards, and bootstrap wiring right on the first try; a misshaped agent ships either a dead handler or a duplicate-firing one, so depth over speed is the right default."} } }