Skip to content

[FEATURE]: Plugin API: hook before the host stores synthetic text #57214

Open

Description

@iceteaSA

Problem

ctx.session.synthetic(...) stores text without the session.prompt hook. A filtering plugin uses that hook for usual prompts. The plugin cannot filter synthetic text before storage.

Source (v2.0.31)

  • packages/core/src/session/session.ts:273 gives Session.synthetic. At line 298 it calls admission.admit directly:

    const admitted = yield* admission
    .admit({

  • packages/core/src/session/prompt.ts:40 shows that a usual prompt goes through the hook first:

    const event = yield* hooks.trigger("session", "prompt", {

  • The synthetic path does not call SessionPrompt.prepare.

Purpose

A plugin removes secrets and unwanted text from user input. The plugin filters at the prompt hook. Synthetic text from the host or from a different plugin goes around the filter.

Proposed minimal API

Start the session.prompt hook before the host stores the synthetic text, or add a session.synthetic hook with the same shape.

What the plugin does today without it

The plugin cannot filter synthetic text before storage. The plugin can filter then, at the session.context hook, but only after the host stores the text.

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up and the source pointers!

    To restate the request: you'd like a plugin hook that runs before the host stores synthetic text, either by also triggering session.prompt for synthetic input or through a new session.synthetic hook with the same shape. That way a filtering plugin can remove secrets and unwanted text from synthetic input before it's saved, not only afterwards at session.context.

    I checked the code and it matches your description: Session.synthetic passes the item straight to admission.admit without calling SessionPrompt.prepare, and no plugin hook currently runs before synthetic text is stored. One more detail: some synthetic text, such as instruction messages, is published without going through Session.synthetic at all, so a complete fix would need to cover those routes too.

    Maintainers will consider this. Related issues on redacting text before it's stored:

  2. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    There are two storage boundaries to distinguish here. Session.synthetic reaches the durable InboxEnqueued event before delivery, so filtering at promotion would already be too late for pre-storage filtering. A hook for this path needs to run before a new admission, while retaining the existing first-admission-wins behavior for repeated IDs.

    For the internal routes mentioned above, SessionInstructions.load explains why it publishes directly: routing through Session creates a dependency cycle. Its metadata.instruction.paths is also the lasting dedup ledger. Giving a filtering hook unrestricted replacement of that metadata could cause instructions to be re-injected or incorrectly treated as loaded.

    Would a dedicated synthetic hook with host-owned origin information and text mutation be the preferred starting point? I'd keep instruction bookkeeping host-owned and make its inclusion an explicit design decision; wiring only ctx.session.synthetic would not establish coverage of internal instruction/restart/continuation events.

  3. iceteaSA commented on Oct 20, 2026

    @iceteaSA
    Author

    Yes. A dedicated synthetic hook with host-owned origin information and text mutation is the right starting point for us. What we need from it:

    • Timing: it runs before a new admission (before InboxEnqueued), so the stored inbox payload is already filtered.
    • New IDs only: a retry with an existing ID keeps first-admission-wins and does not run the hook again.
    • Read-only origin: at least the calling plugin ID (or the host) and the route (plugin synthetic, prompt, restart, continuation).
    • Text-only mutation: metadata stays as admitted, so a filter cannot change dedup ledgers such as metadata.instruction.paths.
    • Fail closed: if the hook throws, the admission fails, the same as an execute.before throw today.

    Instruction text: agreed, keep the bookkeeping host-owned. Instruction files are operator-controlled, and we already filter their text before each request in the context hook. So it is acceptable for us if the first change covers only ctx.session.synthetic, provided the docs name the routes the hook does not cover. The case we hit in tests is plugin synthetic text: one plugin admitted tool output with ctx.session.synthetic, and the raw value was in session_message before any hook ran.

  4. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    That gives the first change a clear scope: ctx.session.synthetic, with the other synthetic writers explicitly excluded.

    Two implementation details matter for those guarantees:

    • Origin needs a host binding. The inspected host.ts forwards synthetic: sessions.synthetic directly. The host needs to supply its bound plugin identity and a plugin-synthetic route, rather than trusting caller metadata. The hook should receive only replaceable text plus read-only context; handing it the original mutable metadata object would defeat the text-only restriction.
    • New-ID filtering needs concurrency coordination. admit reconciles an existing ID before publishing. An unlocked lookup followed by a hook would let two concurrent callers both run that hook before either admission exists. Lookup, preparation and admission need coordination that preserves existing lifecycle conflicts. That design also needs to handle re-entrant admission from a hook without introducing a same-session deadlock.

    For fail-closed behavior, a preparation failure should produce no InboxEnqueued, pending row or wake for that admission, leaving a previously unused ID available for a later successful attempt.

    I'd use these acceptance cases: filtered text in the durable inbox and delivered message; unchanged metadata; an existing-ID retry without another hook call; concurrent same-ID submissions; and a throwing hook leaving no admitted input. These are proposed checks, not results I've run.

    The docs should explicitly exclude normal prompts, internal instruction/restart/continuation writes, and instruction-state storage. Listing those names in an origin enum must not imply this first hook intercepts them.

  5. iceteaSA commented on Oct 20, 2026

    @iceteaSA
    Author

    Agreed on both details: a host-bound plugin identity with a plugin-synthetic route, and the hook gets only replaceable text plus read-only context. The fail-closed rule (no InboxEnqueued, no pending row, no wake, ID still unused) is exactly what we need.

    One case to add to the acceptance list: a hook that itself calls ctx.session.synthetic for the same session, for example to admit a warning notice about what it filtered. It must not deadlock, and it must not run the hook again on its own notice in a loop. Whether that inner call is refused or admitted after the outer admission, please document the rule.

    When a branch exists, we can run it against our secret-filter tests, which plant credentials in synthetic text and then scan the inbox, messages and logs.

  6. Dante-dan commented on Oct 21, 2026

    @Dante-dan
    Contributor

    Agreed — same-session reentrancy should be a separate acceptance case, with a defined outcome rather than just a timeout check.

    For v1, I propose rejecting a nested ctx.session.synthetic call targeting the session whose hook is currently executing. That check must happen before waiting on an in-flight ID or acquiring admission coordination, including when the nested call reuses the outer ID. It should return a specific reentrancy error without invoking another hook, admitting an input, or emitting a wake. Normal callers outside that hook invocation keep the existing retry/concurrency behavior.

    The hook execution context needs to carry this restriction through the host boundary; plugin-supplied metadata must not be able to suppress it. Simply releasing a session lock around the hook would avoid one deadlock while still permitting recursive hook calls.

    I'd add these explicit acceptance assertions:

    • A hook awaits a same-session synthetic call with a fresh ID: the nested call promptly rejects, the hook is invoked once, and no nested input is persisted or woken.
    • Repeat with the outer input's ID: the nested call rejects before waiting on its own in-flight admission.
    • If the hook catches that error and returns replacement text, only the outer input is admitted with that text. If it lets the error escape, the outer admission also fails closed, with no InboxEnqueued, pending row or wake for either call.
    • After the hook finishes, a normal call using a previously unused rejected ID can succeed; the rejection must not leave an admission reservation behind.

    These are proposed contract and regression assertions, not executed test results.

  7. iceteaSA commented on Oct 21, 2026

    @iceteaSA
    Author

    Rejecting a nested same-session synthetic call works for us. If our hook must report what it filtered, we can admit that warning after the outer admission completes.

    Two requests for the contract:

    • Make the reentrancy rejection a tagged error, like SessionBusyError, so that a plugin can tell it apart from busy and not-found errors and defer the warning.
    • Please state what happens when the hook calls ctx.session.prompt (not synthetic) for the same session. That call does not run the hook, so it cannot recurse, but it can wait on the same admission coordination. It should either succeed without a deadlock or get the same typed rejection.

    Your four assertions match the cases we plan to run against a branch.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions