Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/cli/src/local-personas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/local-personas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/persona-kit/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions packages/personas-core/personas/proactive-agent-builder.json
Original file line number Diff line number Diff line change
@@ -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/<name>/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: \"<kebab-case-id>\", // unique within the workspace\n schedule: { cron: \"0 9 * * 1\", tz: \"UTC\" }, // OR watch: \"/<vfs>/**\" 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: \"<id>\", trigger: \"time\", action: \"<short>\", summary: \"<one line>\", outcome: \"success\",\n });\n },\n\n async onError(ctx, error, event) {\n ctx.logger.error(\"<id> failed\", { error: error.message, eventId: event.id });\n await writeLogEntry(ctx, {\n agent: \"<id>\", trigger: \"time\", action: \"<short>\", summary: error.message, outcome: \"error\",\n });\n },\n});\n```\n\n`AgentEvent.type` is one of `\"cron.tick\" | \"relayfile.changed\" | \"relaycast.message\" | \"<provider>.<verb>\"`. `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/<id>/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(\"<id>: 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/<id>/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(\"<user-or-agent>\", 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: <env().LINEAR_API_KEY>`. Use the `issueCreate` mutation. Wrap in `ctx.once(\"<id>:<dedup-key>\", ...)` 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/<id>/agent.ts`.** Mirror the template's shape. Use the env shim. Use `ctx.once` around every side-effecting upsert. Persist dedup state in `/_internal/<id>/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/<id> -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:<weekKey>\"`, 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
}
}
}
}
3 changes: 2 additions & 1 deletion packages/workload-router/routing-profiles/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."}
}
}
Loading