Skip to content

feat(persona-registry): let handler agents into the cascade - #323

Open
willwashburn wants to merge 3 commits into
mainfrom
feat/registry-handler-personas
Open

feat(persona-registry): let handler agents into the cascade#323
willwashburn wants to merge 3 commits into
mainfrom
feat/registry-handler-personas

Conversation

@willwashburn

@willwashburn willwashburn commented Aug 20, 2026

Copy link
Copy Markdown
Member

Unblocks the P1s found on #320, at their source.

An agent driven by its onEvent entry has no interactive launch to configure. PersonaSpec already types harness, model, and systemPrompt as optional and already models onEvent/cloud — but the registry's standalone builder demanded all three and dropped the handler fields on the floor. The result: the agents/ directory added in #316 could not load the class of agent it was added for, and said so in a way that read as "your persona is malformed".

# before — a handler persona with harness/model/systemPrompt omitted
$ agentworkforce list
warning: [cwd:agents] digest: standalone persona "digest".harness is required for standalone personas
(no digest row)

# after
$ agentworkforce list
digest    cwd:agents    —    —    documentation

is honest here: the agent has no harness, and now says so instead of being invisible.

Overlays keep the handler

onEvent and cloud survive parse and merge, so an overlay that only tweaks env no longer strips what makes its base deployable:

# personas/digest.json = {"id":"digest","env":{"TONE":"terse"}}
# agents/digest/persona.json = the real agent
$ agentworkforce show digest --json
onEvent = ./agent.ts | cloud = true | env = {TONE: terse}

That is the registry half of #320's second P1. The remaining half is deploy consuming the merged spec instead of a declaring path, which is the open API question on that PR.

What this deliberately does not do

harnessSettings stays required. PersonaSpec types it non-optional, and reasoning/timeoutSeconds have no defensible default to invent on a persona's behalf — inventing one would silently run a handler that calls ctx.harness.run() with settings nobody chose. Every shipped handler example (examples/weekly-digest, examples/review-agent) declares it. A handler persona that omits harnessSettings entirely still cannot load; making that work means deciding whether PersonaSpec.harnessSettings becomes optional, which is a published-type change in @agentworkforce/persona-kit that the cloud runtime also consumes. Flagging rather than deciding.

Integrations, triggers, and schedules are likewise still not modelled by the local override — deploy reads those from the file directly. Merge semantics for them are a separate design question.

Verification

Both original repros re-run against the built CLI. Four new tests cover the handler persona loading without interactive fields, a non-handler standalone still requiring harness, an overlay preserving the inherited handler entry, and onEvent rejected when it escapes the agent directory. local-personas suite passes (55). Lint and typecheck clean.

Semver: minor — the registry accepts a persona shape it previously rejected; no existing persona changes behavior.

🤖 Generated with Claude Code

Review in cubic

An agent driven by its `onEvent` entry has no interactive launch to configure,
so `harness`, `model`, and `systemPrompt` — already optional on `PersonaSpec` —
are optional here too. Requiring them kept exactly the agents the `agents/`
directory was added for out of the registry, reporting a valid deployable
persona as malformed.

`onEvent` and `cloud` now survive parse and merge. An overlay that tweaks env no
longer strips the handler entry that makes its base deployable, so the merged
spec is a complete agent rather than a partial one.

`harnessSettings` stays required: `PersonaSpec` types it non-optional, and
`reasoning`/`timeoutSeconds` have no defensible default to invent on a
persona's behalf.

`onEvent` is validated as a relative path that cannot escape the agent
directory, matching the sidecar rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@willwashburn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4f10d85-a59f-40c4-973a-202270b17362

📥 Commits

Reviewing files that changed from the base of the PR and between 558784d and 41b059b.

📒 Files selected for processing (2)
  • packages/cli/src/local-personas.test.ts
  • packages/persona-registry/src/local-personas.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4122f83ceb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (typeof raw.onEvent !== 'string' || !raw.onEvent.trim()) {
throw new Error(`${context}.onEvent must be a non-empty string if provided`);
}
assertSafeRelativePath(raw.onEvent, `${context}.onEvent`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the normalized handler path

Validate the same trimmed value that is stored. For example, onEvent: " ../outside/agent.ts " passes this check because its first segment is " ..", but line 672 trims it to ../outside/agent.ts; downstream deployment then resolves and bundles a handler outside the agent directory, defeating the containment guard.

Useful? React with 👍 / 👎.

Comment on lines +520 to +523
if (value.split(/[\\/]+/).some((segment) => segment === '..')) {
throw new Error(`${context} must not contain ".." segments`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce a handler source-file extension

Require the same .ts/.tsx/.mts/.cts/.js/.mjs/.cjs extensions accepted by persona-kit's parseOnEvent. As written, a standalone file with onEvent: "README.md" is classified as a handler, so it can omit harness, model, and systemPrompt and still enter the cascade even though deploy will reject it as an invalid handler.

Useful? React with 👍 / 👎.

Comment on lines +1169 to +1171
// An overlay that only tweaks env must not strip the handler entry that
// makes the base a deployable agent.
const onEvent = override.onEvent ?? base.onEvent;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the handler path's declaring directory

When a personas/digest.json overlay inherits ./agent.ts from agents/digest/persona.json, this retains the string but loses its provenance: loaded.paths points to the winning overlay file, while onEvent is defined relative to the lower file. Any consumer following the PersonaSpec contract and resolving the handler relative to the returned persona path therefore looks for personas/agent.ts rather than agents/digest/agent.ts, so the newly tested env-only overlay no longer identifies a deployable handler.

Useful? React with 👍 / 👎.

@willwashburn

Copy link
Copy Markdown
Member Author

Verified the failure mode this opens up, since handler personas can now reach code that previously never saw one.

resolvePersonaReference (the SDK path, used by local-surface and embedders) already carried a not_interactive error code for exactly this shape. It was unreachable before, because such personas never got into byId:

$ resolvePersonaReference("digest")
PersonaResolutionError  code: not_interactive
  persona "digest" cannot be spawned interactively because it omits harness, model, systemPrompt

The CLI's own buildSelection has a matching guard, with a comment that reads as though it was written for this PR (cli-impl.ts:556):

An interactive agentworkforce agent run spawns a harness session, which requires harness/model/systemPrompt. These are optional on the spec only for handler-style (onEvent) personas, which are deployed — not run interactively — so reject them here with a pointed error.

And the list table already renders spec.harness ?? '—'.

So the interactive surfaces were built expecting handler personas in the registry; the standalone builder was the one place that hadn't caught up. This change completes that design rather than altering it, and the new failure mode is a typed error pointing at deploy, not a crash.

I did not run agentworkforce agent digest end to end — the repo rule against spawning real harnesses applies, and the guard throws before any spawn.

Hand-rolling the guard drifted from persona-kit twice over. It validated the
raw string and stored a trimmed copy, so `" ../x/agent.ts "` cleared the `..`
check as the segment `" .."` and escaped the agent directory once trimmed. And
it never checked the handler extension, so `onEvent: "README.md"` counted as a
handler and skipped the interactive fields the persona never declared.

`parseOnEvent` owns both rules and returns the exact string it validated, so
the stored value cannot differ from the one that passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@willwashburn

Copy link
Copy Markdown
Member Author

All three P2s validated. Two are fixed; the third is the open API decision on #320.

Trim-vs-validate and the missing extension rule — both correct, both fixed. They shared a cause: I hand-rolled the guard instead of using persona-kit's. It validated the raw string and stored a trimmed copy, so " ../x/agent.ts " cleared the .. check as the segment " .." and escaped once trimmed. And it never checked the extension, so onEvent: "README.md" read as a handler and skipped the interactive fields the persona never declared.

Now delegated to parseOnEvent, which owns both rules and returns the exact string it validated — the stored value cannot differ from the one that passed. Two tests pin the cases.

Declaring-directory provenance — correct, and it is the blocked decision. An overlay that inherits ./agent.ts wins loaded.paths, so a consumer resolving the handler against the returned path looks in personas/ rather than agents/digest/.

Resolving onEvent to an absolute path at load, the way claudeMd is resolved, does not work here: parseOnEvent rejects absolute paths, so a resolved spec would no longer survive a parsePersonaSpec round-trip — and deploy performs exactly that round-trip on what it reads. The fix has to carry the handler-owning directory alongside the spec, which is the same API question already open on #320:

  • does @agentworkforce/deploy accept a PersonaSpec plus an owning directory, or does the CLI materialize the merged spec to a temp file in that directory?

I would rather land that deliberately than pick a shape here. Until it lands, this PR leaves overlay-shadowed handlers exactly as it found them — the difference is that the inherited onEvent now survives the merge at all, where before it was dropped outright.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/persona-registry/src/local-personas.ts Outdated
Order matters in both directions. Validating the raw string and storing a
trimmed copy let `" ../x/agent.ts "` clear the `..` check as the segment `" .."`
and escape once trimmed. Validating without trimming stored `" ./agent.ts"`,
which passes every check and then resolves against a directory named `" ."` at
deploy.

Trimming before `parseOnEvent` makes the validated value and the stored value
the same string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@willwashburn

Copy link
Copy Markdown
Member Author

Valid, and it is the correct synthesis of the two findings on this line rather than a reversal of the earlier one.

Order matters in both directions:

  • validate raw, store trimmed → " ../x/agent.ts " clears the .. check as the segment " .." and escapes once trimmed (the earlier finding)
  • validate raw, store raw → " ./agent.ts" passes every check and then resolves against a directory named " ." at deploy (this finding)

Trimming before parseOnEvent collapses both: the validated string and the stored string are the same, and the traversal guard sees the normalized path. " ../x/agent.ts " is now rejected by the .. rule rather than incidentally by the extension rule, and " ./agent.ts" stores as ./agent.ts.

Two tests pin it — the padded-traversal case now asserts the traversal message specifically, and a new case asserts the stored value is normalized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant