Skip to content

Add plugin agent types: agents a plugin defines and the app runs - #621

Closed
baxen wants to merge 5 commits into
mainfrom
art3/plugin-agent-types
Closed

baxen wants to merge 5 commits into
mainfrom
art3/plugin-agent-types

Conversation

@baxen

@baxen baxen commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Why

A plugin should be able to contribute a kind of agent: its settings form, what it listens for, and the function that runs. This is the first landable cut of the two prototypes (checkpoint/plugin-agents and art4/coding-agent), ported onto current main. Design sketch and review are in the Buzz thread in #buzz1-ittt.

What

  • ctx.agentTypes.register({ id, title, defaults, Configure, validate, subscription, run, timeoutMs, concurrency, secrets }).
  • An agent made from a type is an ordinary agent record with plugin: { type, config }. Create, the key, the owner attestation and the profile use the existing native path. It has no process; Start and Stop set enabled.
  • The service matches each enabled agent's NIP-01 filter against session.subscribeLive, the live events the owner's session already receives. It opens no relay subscription.
  • agent.publish signs kinds 9, 40003, 7 and 5 natively as the agent and posts over HTTP. The key never enters the WebView.
  • secrets are write-only values saved in the agent's environment and read with agent.secret(name) during a run.
  • The identity a run receives is made for that run. publish and secret reject once the run has ended, and signal aborts at the same moment. In the prototypes they were built once per agent and ignored the run's deadline, so a function that outlived its timeout could still publish while the next queued run was in progress.

Left out on purpose:

  • Token streaming, in both forms: editing a published message, and streamed text in a local view.
  • The live run view, the workspace and command execution. These are later PRs in the sketch.
  • The type exports in src/plugins/author.ts. That file is FOUNDATION and the edit awaits approval, so an external plugin can call ctx.agentTypes but cannot yet import its types from @buzz/author.
  • An example plugin. A model-calling example needs ctx.host.fetch (Add ctx.host.fetch: a streamed host request for plugins #530).

Known limits

  • Agents run only while the app is open with the agent's community selected. Delivery is live-only and at most once.
  • agent.secret returns the value into the WebView, which all enabled plugins share.
  • A new agent is not joined to any channel, so it can be delivered a mention from a private channel it cannot post to.

Validation

Checked at 44ecc4d9.

  • bin/pnpm typecheck passed.
  • Vitest passed for src/features/agent-types, src/features/agents, src/features/relay/live.test.ts and src/bundled/agents (45 files, 525 tests).
  • bin/cargo test -p buzz-agent-controller passed (145 tests, 2 ignored). bin/cargo test -p buzz-foundation browser_permissions passed.
  • Pre-commit and pre-push hooks passed: Biome, related unit tests, design-system checks, Clippy.
  • The new test "ends a run's hold on its agent when the run ends" fails against the per-agent identity and passes with the per-run one.

Not done: I have not run the app or created an agent through the dialog on this branch. The full Vitest suite, the browser tests and just scan were not run locally. This is ready to try, not validated.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested: four P2 correctness/user-control defects are detailed inline. The three P3 notes are optional and do not gate this draft.

Reviewed head 44ecc4d9817f8ce3f77bcb31d89acaaac454c1cf against target base be00aa0ce0931f9a7e27019170e89f8528c3ea95 (merge base 71c0644a09d44d6696be842978a8c042a5f29cd6). Existing applicable CI checks are green; Windows native validation was skipped. Five additional review-only Vitest/RTL probes passed on that head with no production edits. Those probes demonstrate current behavior, not fixes; native signing/credential conclusions are source-traced.

Before merge: address the four P2 threads with regression coverage and exercise the create/edit/run/stop flow in the app. Neither this review nor the PR’s reported validation exercised a real plugin-agent Keychain/relay workflow. The documented shared-WebView trust, live-only delivery, selected-community operation, and new-agent membership limitations are accepted scope, not additional blockers.

Comment thread src-tauri/src/agents.rs Outdated
Comment on lines +1400 to +1406
// Stop or delete while the key was being read must win over this send.
let pubkey = identity.pubkey.clone();
run(owner, move |host| {
(host.controller.plugin_identity(&id)?.pubkey == pubkey)
.then_some(())
.ok_or_else(|| "Agent identity changed".into())
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Fence the originating run after the credential wait

A publish can enter native while its run is valid, then block on a cold Keychain read at lines 1385–1391. If the run times out, settings are saved, or the plugin/session is replaced before that read completes, this check still succeeds: plugin_identity checks the current enabled flag and pubkey, not the originating run or saved revision. Stop→Start also passes because it restores enabled without changing the revision. The stale event is then sent for the first time after revocation, potentially during a newer run, contrary to the per-run guarantee in docs/plugin-architecture.md:1007–1012.

Carry a run lease/generation and deadline across IPC and revalidate it, plus the saved revision, after credential acquisition and immediately before send. Invalidate it on the documented cancellation paths, including Stop→Start. Add a gated-credential regression asserting no HTTP send after revocation. A JS service probe reproduces the missing cancellation boundary; the native credential/send ordering was source-traced, not exercised against real Keychain.

Comment thread src-tauri/src/agents.rs Outdated
})
.await?;
let (identity, key) = tauri::async_runtime::spawn_blocking(move || {
credentials.retry();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Do not retry a denied credential unlock on every automated publish

credentials.retry() clears the shared bundle's Denied latch (credentials/bundle.rs:23–26). After a user refuses an unlock, the next incoming event that causes this agent to publish therefore retries the OS credential operation without another user action. This directly violates Credentials::retry's deliberate Start/Retry contract (secret.rs:204–205) and the existing denial-persistence test; automatic traffic must not repeatedly reopen a refused unlock.

Remove the unconditional retry from publication and expose recovery through an explicit user-controlled Start/Retry path. Cover denial followed by multiple automatic publishes, then an explicit retry. The latch reset is confirmed from source; repeated native dialogs were not tested.

Comment on lines +147 to +151
const invalid = agentTypeError(
current,
types,
agent.harness.environmentKeys,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Supply the type registry at every editor entry point

This validation makes plugin-agent Save depend on agentTypes, but only the Agents-page caller supplies it. ProfileRuntime.tsx:232, ProfileAgentRuntime.tsx:101, and AgentUpdateReview.tsx:260 omit the registry; the profiles plugin does not inject it either. Opening an enabled plugin agent from its profile therefore falsely reports that its type is unavailable and rejects even a name change.

An RTL probe mounted ProfileAgentRuntime with a registered active type: opening the editor and saving a changed name produced the unavailable-type error and no save call. Passing the same registry directly to AgentEditor displayed the configuration form. Thread the registry through all supported entry points and cover those mounts. The profile runtime surface also needs to omit process-only controls such as Start on launch and Harness log for these agents.

Comment on lines +54 to +61
/** An emptied field sends nothing, so a saved value stays as it is. */
function withSecret(
environment: AgentDraft["environment"],
name: string,
value: string,
) {
const { [name]: _, ...rest } = environment;
return value ? { ...rest, [name]: value } : rest;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Provide an explicit removal action for saved optional secrets

Blank input intentionally preserves a saved value, but this form has no other path that emits the native deletion patch ({ TOKEN: null }). Unlike harness agents, plugin agents do not render AgentEnvironmentEditor's Remove control. Once an optional token is saved, the user cannot remove it from this agent through the UI while keeping the identity; typing a replacement and clearing it only sends {} and retains the old token.

Add a host-owned “Remove saved value” action that sends null, with required-secret validation still enforced. Also allow removing saved secret names a later type version no longer declares. An RTL probe confirms that clearing the input produces an empty environment patch and that no remove/clear/delete action is rendered.

Comment on lines +307 to +311
// Values typed for one runtime must not be saved on another.
const { plugin: _, ...harness } = {
...draft,
environment: {},
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3, optional] Keep the harness worker seed conditional on the selected runtime

Harness → plugin → Harness resets environment to {}, losing the documented BUZZ_ACP_AGENTS=10 default for a new harness agent. Conversely, duplicating a plugin agent takes the unconditional seed at line 91 and saves this process-only override onto an agent whose form cannot remove it. Seed the worker override only for harness drafts, both on initialization and when switching back; add cases for switching and plugin duplication. Source-traced; not a merge blocker on its own.

Comment on lines +989 to +995
Registering a type or creating an agent opens no relay subscription. The service
listens to `session.subscribeLive` on the selected community's ready session and
matches each event locally, for every enabled agent of an active type whose
community is the selected one. An agent therefore sees what its owner's connection
already streams: the channel kinds in `features/relay/live.ts` for the channels the
owner has joined, plus the global routes. Replayed history, finite reads and local
unsent intent never run it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3, optional] Describe the window-dependent live-input limits

The new listener receives accept()'s visibility-filtered output, not every live wire event. eventVisibility requires locally retained evidence for every e target of reactions, edits and deletions, even with a valid h tag. Thus a live reaction to an older, unloaded message can be dropped while the socket is healthy, and two windows can deliver different inputs. The stream also includes previewed channels, not just joined ones. Please document these limits rather than weakening the existing access check to obtain wider delivery.

Also clarify that leaving a channel stops new delivery but does not cancel events already queued in AgentTypesService; the service receives no channel-access revocation signal. These are source-traced contract clarifications, not requests to add subscriptions or reliable catch-up in this cut.

Comment on lines +519 to +522
const signal = AbortSignal.any([
instance.controller.signal,
AbortSignal.timeout(instance.type.timeoutMs ?? 30_000),
ended.signal,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P3, optional] Validate timeout overrides before registration

register validates concurrency but accepts timeoutMs: -1 (confirmed by a focused probe). AbortSignal.timeout(-1) then throws here, before the try, so the failure bypasses the promised error counter and becomes a rejected fire-and-forget execution. Reject invalid/non-integral timeout values during registration, or construct the signal inside the handled path. This is invalid-plugin-input hardening, not a blocker for correctly configured types.

ART3 added 3 commits October 6, 2026 13:00
A plugin registers an agent type through ctx.agentTypes: a config page, a
function from config to a NIP-01 subscription, and a run function. Each agent
created with that type gets its own native bot identity and owner attestation
through the existing Create agent path, and runs in the app with no process.

The app matches each enabled agent's subscription against live events the
session already streams and calls run with the agent identity and saved config.
agent.publish signs kinds 9, 40003, 7 and 5 natively as the agent and posts
them over HTTP; the key never enters the WebView.

Ported from b054338 onto current main. The type exports for plugin authors
in src/plugins/author.ts are left out: that file is FOUNDATION and the edit
awaits approval.

Signed-off-by: ART3 <822f566773c6b5ec5de21614f3900e5dd78a36d772fb826a3ee0f4b140d68255@buzz.block.builderlab.xyz>
A type declares write-only secrets, which the host asks for under Configure
and saves in the agent's environment; agent.secret(name) reads one during a
run. A type may also set concurrency, from 1 to 16 runs in progress per agent.

The identity a run receives is now made for that run. publish and secret
reject once the run has ended, and the run's signal aborts at the same
moment. Before, they were built once per agent and ignored the run's
deadline: a function that outlived its timeout could still publish while the
next queued run was in progress.

Ported from 56df2f4 without its live run view. The edit guidance for
streaming text through repeated edits is removed from the docs.

Signed-off-by: ART3 <822f566773c6b5ec5de21614f3900e5dd78a36d772fb826a3ee0f4b140d68255@buzz.block.builderlab.xyz>
Native now issues a lease per run (agent_identity_run_begin/end), bound to
the saved revision and the run's deadline. Publish and secret reads check
it before and after the credential read, and Stop, Start, save and delete
end it, so a publish waiting on the Keychain when its run ends never sends.

Automated publishes no longer call credentials.retry(); only a deliberate
Start reopens a refused unlock.

The agent type registry reaches every AgentEditor entry point (profile
runtime, profile agent runtime, update review), and the profile surface
omits Start on launch and Harness log for plugin agents. Saved optional
secrets and secrets a type no longer declares get a Remove saved value
action that sends null. timeoutMs is validated at registration, and the
docs describe the window-dependent limits of live input.

Signed-off-by: ART3 <822f566773c6b5ec5de21614f3900e5dd78a36d772fb826a3ee0f4b140d68255@buzz.block.builderlab.xyz>
@baxen
baxen force-pushed the art3/plugin-agent-types branch from 44ecc4d to c7c703a Compare October 6, 2026 20:44
ART5 added 2 commits October 6, 2026 16:21
A plugin agent that exists always listens: the snapshot reports it as
running, Start, Stop and Restart are refused for it, and the service
listens for every plugin agent of an active type regardless of the saved
enabled flag. The UI no longer offers Start, Stop or Restart for plugin
agents on the card, editor or profile. Silence an agent by deleting it or
disabling its plugin.

With no Start or Stop to race against, the native per-run lease
(agent_identity_run_begin/end, run ids on publish and secret, identity
revision) is removed. Delete still wins over an in-flight send through
the existing existence check, and the JS run scope still rejects publishes
that start after a run ends. Publish still never re-asks for a refused
unlock.

Signed-off-by: ART5 <a7397738d0f05003030d4487431db3096f2f48e09df21eb15f91f3a9aecf5f0c@buzz.block.builderlab.xyz>
Signed-off-by: ART5 <a7397738d0f05003030d4487431db3096f2f48e09df21eb15f91f3a9aecf5f0c@buzz.block.builderlab.xyz>
@salman1993

salman1993 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

🤖 We exercised this API while building a Codex agent plugin. The split between host-owned identity/publishing and plugin-owned configuration/execution worked well.

Two issues to fix before landing:

  • Plugin profiles show “Only the owner”, but subscriptions can accept other authors. The displayed instruction policy should match actual behavior.
  • Saved plugin agents appear “Running” even when their plugin is disabled or unavailable. Status should reflect whether they’re actually listening.

For follow-ups, long-running agents would benefit from priority control delivery for stop/steer which reach an active turn immediately, per-conversation queues, and structured activity reporting. Those don’t need to expand this foundational PR.

@baxen

baxen commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Closing as stale (open >3 days, conflicts with main) per baxen. Branch kept; reopen if still wanted.

@baxen baxen closed this Oct 9, 2026
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.

3 participants