Skip to content

feat(think): host bridge, permissions, sandboxed hook dispatch (Phase 3+4)#1284

Open
threepointone wants to merge 2 commits intomainfrom
now-more-think
Open

feat(think): host bridge, permissions, sandboxed hook dispatch (Phase 3+4)#1284
threepointone wants to merge 2 commits intomainfrom
now-more-think

Conversation

@threepointone
Copy link
Copy Markdown
Contributor

@threepointone threepointone commented Apr 9, 2026

Summary

Completes the Think extension system (Phases 3 and 4). Sandboxed extension Workers can now participate in lifecycle hooks, access context blocks and messages via host RPC, and have their capabilities gated by a granular permission model.

Builds on #1278 (Phase 1+2: lifecycle hooks, dynamic context, extension manifest).

Phase 3 — Host bridge + permissions

Host bridge methods on Think — 9 new _host* methods that HostBridgeLoopback calls via DO RPC:

  • Workspace: _hostReadFile, _hostWriteFile, _hostDeleteFile, _hostListFiles (delegate to this.workspace)
  • Context: _hostGetContext, _hostSetContext (delegate to this.session)
  • Messages: _hostGetMessages (serialized history), _hostSendMessage (routes through saveMessages/TurnQueue)
  • Session: _hostGetSessionInfo ({ messageCount })

Expanded permissionsExtensionPermissions now includes:

  • context: { read: string[] | "all"; write: string[] | "own" } — per-label context access
  • messages: "none" | "read" — conversation history access
  • session: { sendMessage: boolean; metadata: boolean } — session capabilities

Expanded HostBridgeLoopback — 6 new permission-gated methods: getContext, setContext, getMessages, sendMessage, getSessionInfo. Each has a dedicated permission check with descriptive errors.

Re-entrancy guard_insideInferenceLoop flag tracks stream consumption in _streamResult and chat(). _hostSendMessage routes through saveMessages which queues via TurnQueue — safe during inference (message executes after current turn completes).

Phase 4 — Sandboxed hook dispatch

Extension source format — structured { tools, hooks }:

({
  tools: {
    greet: {
      description: "Greet someone",
      parameters: { name: { type: "string" } },
      execute: async (args) => "Hello, " + args.name
    }
  },
  hooks: {
    beforeTurn: async (ctx) => {
      if (ctx.messageCount > 50) return { maxSteps: 3 };
    }
  }
})

Hook discoverymanifest() RPC returns { hooks: [...] }. Think discovers hooks at load time. getHookSubscribers(hookName) returns extensions in load order.

Pipeline dispatch_pipelineExtensionBeforeTurn runs after subclass beforeTurn:

  1. Creates TurnContextSnapshot (plain serializable data: system, toolNames, messageCount, continuation, body, modelId)
  2. For each subscriber: calls entrypoint.hook("beforeTurn", snapshot) with timeout
  3. Parses result, merges into accumulated TurnConfig (scalars last-wins, providerOptions deep-merge)
  4. model and tools skipped — not serializable across RPC. Use activeTools instead.
  5. Logs warnings on timeout/error, continues pipeline

TimeoutshookTimeout property (default 5s). Timer properly cleared on success.

load_extension tool — updated description documents { tools, hooks } format and host capabilities.

Test plan

  • 217 tests pass across 8 files
  • Phase 3: 9 host bridge tests (workspace read/write, messages, session info, context set/get, re-entrancy flag)
  • Phase 4: 9 integration tests with real WorkerLoader (structured format, hook discovery, hook invocation, snapshot data flow, error handling, timeout behavior, tools-only extensions)
  • All existing tests still pass (200 from Phase 1+2)
  • Build succeeds
  • No other packages affected

Design decisions

  • Plain data snapshots, not RPC proxiesTurnContextSnapshot is a plain object that survives structured clone. We originally planned RpcTarget proxies with lazy methods, but class instances lose their methods during Workers RPC serialization. Snapshots are simpler and correct.
  • Sandboxed extensions can't override model or tools — these types aren't serializable across the RPC boundary. Extensions control tool availability via activeTools (string array). Only subclass hooks can swap the model or add AI SDK tool objects.
  • result ?? {} not result || {} — prevents falsy coercion when extensions return { maxSteps: 0 }.
  • Snapshot created once per pipeline — all extensions see Think's original assembled context, not each other's modifications. Acceptable trade-off for simplicity.

Made with Cursor


Open with Devin

…uard

Phase 3 of the Think extension system redesign. Think now implements
the _host* methods that HostBridgeLoopback calls via DO RPC,
permissions are expanded with context/messages/session capabilities,
and the inference loop tracks re-entrancy for safe sendMessage.

HOST BRIDGE METHODS ON THINK:

Workspace (delegating to this.workspace):
  - _hostReadFile(path) → readFile
  - _hostWriteFile(path, content) → writeFile
  - _hostDeleteFile(path) → rm (returns false on error)
  - _hostListFiles(dir) → readDir (maps to {name, type, size, path})

Context (delegating to this.session):
  - _hostGetContext(label) → getContextBlock content
  - _hostSetContext(label, content) → replaceContextBlock

Messages:
  - _hostGetMessages(limit?) → serialized history as {id, role, content}
    (extracts text parts, respects limit via slice)
  - _hostSendMessage(content) → creates user message, routes through
    saveMessages which queues via TurnQueue (safe during inference —
    message executes after the current turn completes)

Session metadata:
  - _hostGetSessionInfo() → { messageCount }

EXPANDED PERMISSIONS:

ExtensionPermissions now includes:
  - context: { read: string[] | "all"; write: string[] | "own" }
    Per-label context block access. "own" trusts the extension to
    only write its namespaced labels (not validated against manifest;
    namespace prefixing makes cross-extension writes unlikely).

  - messages: "none" | "read"
    Conversation history access.

  - session: { sendMessage: boolean; metadata: boolean }
    Session-level capabilities.

EXPANDED HOSTBRIDGELOOPBACK:

6 new permission-gated methods:
  - getContext(label) — gated by context.read
  - setContext(label, content) — gated by context.write
  - getMessages(limit?) — gated by messages
  - sendMessage(content) — gated by session.sendMessage
  - getSessionInfo() — gated by session.metadata

Each has a dedicated #require* permission check with descriptive
error messages. Existing workspace methods renamed from
#requirePermission to #requireWorkspace for clarity.

RE-ENTRANCY GUARD:

_insideInferenceLoop flag tracks whether a stream is being consumed:
  - Set to true at start of _streamResult (WebSocket/saveMessages/
    continuation) and chat() stream iteration
  - Cleared in finally blocks after stream consumption completes
  - _hostSendMessage uses saveMessages which routes through TurnQueue
    — naturally queues behind the active turn when flag is true

TESTS:

9 new tests:
  - _hostWriteFile + _hostReadFile delegate to workspace
  - _hostReadFile returns null for missing file
  - _hostGetMessages returns conversation history
  - _hostGetMessages respects limit
  - _hostGetSessionInfo returns message count
  - _insideInferenceLoop false outside a turn
  - _insideInferenceLoop false after completed turn
  - _hostSetContext writes to a context block
  - _hostGetContext returns null for non-existent block

209 total tests pass across 8 files.

Made-with: Cursor
…cycle

Phase 4 of the Think extension system redesign. Sandboxed extension
Workers can now participate in lifecycle hooks alongside subclass
overrides. Extensions declare hooks in their source, Think discovers
them via manifest() RPC, and dispatches them in a pipeline during
the inference loop.

EXTENSION SOURCE FORMAT:

Extensions use a structured { tools, hooks } format:

  ({
    tools: {
      greet: {
        description: "Greet someone",
        parameters: { name: { type: "string" } },
        execute: async (args) => "Hello, " + args.name
      }
    },
    hooks: {
      beforeTurn: async (ctx) => {
        if (ctx.messageCount > 50) return { maxSteps: 3 };
      }
    }
  })

Both tools and hooks are optional. The generated Worker class
exposes describe(), manifest(), execute(), and hook() RPC methods.

HOOK DISCOVERY:

- manifest() RPC returns { hooks: [...] } — discovered at load time
- LoadedExtension stores hooks[] alongside tools[]
- getHookSubscribers(hookName) returns extensions in load order
- Legacy manifest() gracefully degraded (try/catch, no hooks)

PIPELINE DISPATCH:

_pipelineExtensionBeforeTurn runs after the subclass beforeTurn hook:
1. Creates TurnContextSnapshot (plain serializable data)
2. For each subscriber: calls entrypoint.hook("beforeTurn", snapshot)
3. Races against hookTimeout (default 5s)
4. Parses result, merges into accumulated TurnConfig
5. Logs warnings on timeout/error, continues pipeline

Merge semantics for sandboxed extensions:
- system, messages, activeTools, toolChoice, maxSteps — last wins
- providerOptions — deep merge
- model and tools — skipped (not serializable across RPC boundary;
  use activeTools to control tool availability)

SERIALIZABLE SNAPSHOTS:

TurnContextSnapshot is a plain data interface (no methods/classes):
  { system, toolNames, messageCount, continuation, body, modelId }

Created by createTurnContextSnapshot(). Survives Workers RPC
structured clone. Extensions read ctx.toolNames, ctx.system, etc.
and return a plain TurnConfig object.

TIMEOUT:

hookTimeout property on Think (default 5000ms). Each extension hook
invocation uses Promise.race with a timer that is properly cleared
on success (no dangling timers).

load_extension TOOL:

Updated description to document the { tools, hooks } format, host
capabilities (getContext, setContext, getMessages, sendMessage), and
hook support.

TESTS (9 new, real WorkerLoader):

- Structured { tools, hooks } format loads correctly
- Hook discovery via manifest() RPC
- Tool execution with structured format
- Tools-only extension has no hooks
- Hook invocation via entrypoint.hook()
- Hook receives snapshot data and computes from it
- Skipped result for unsubscribed hooks
- Error handling for failing hooks
- Network isolation with structured format

217 total tests pass across 8 files.

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

⚠️ No Changeset found

Latest commit: 4816708

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

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.

🔴 Host binding only injected for workspace permissions, not for new context/messages/session permissions

In #loadInternal, the host binding (env.host) is only injected into the extension worker when permissions.workspace !== "none" (packages/think/src/extensions/manager.ts:183-184). However, the PR adds new permission types (context, messages, session) to ExtensionPermissions with corresponding bridge methods in HostBridgeLoopback (getContext, setContext, getMessages, sendMessage, getSessionInfo). An extension that declares only these new permissions (e.g., { context: { read: "all" } }) without workspace access won't receive the host binding, causing this.env.host to be undefined in the extension worker. Its tools will get null for the host parameter (via the this.env.host ?? null fallback at line 417), making the new bridge methods inaccessible.

(Refers to lines 183-184)

Prompt for agents
The condition for injecting the host binding into extension workers only checks workspace permissions. It needs to also check the new permission types (context, messages, session). In #loadInternal in packages/think/src/extensions/manager.ts, the check at lines 183-184 should be updated to create the host binding when ANY permission requiring host access is declared. For example, compute a boolean like `needsHostBinding` that is true when workspace is not 'none', OR context has read/write, OR messages is 'read', OR session has sendMessage or metadata set. Then use that boolean instead of just `wsLevel !== 'none'` in the condition. The comment on lines 179-181 should also be updated to reflect the broader set of permissions.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

abortSignal?: AbortSignal,
options?: { continuation?: boolean; parentId?: string }
) {
this._insideInferenceLoop = true;
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.

🟡 _insideInferenceLoop flag not protected by try/finally in _streamResult, can get stuck as true

In _streamResult, _insideInferenceLoop is set to true at line 1806 and reset to false at line 1920, but this is NOT wrapped in a try/finally. If any code between these lines throws without being caught (e.g., _resumableStream.start() at line 1808, _continuation.activatePending() at line 1813, or if _resumableStream.markError() throws inside the catch/finally blocks at lines 1870/1884), the flag permanently stays true. This contrasts with the chat() method at packages/think/src/think.ts:1126-1139 which correctly uses try/finally for the same flag.

Prompt for agents
In the _streamResult method in packages/think/src/think.ts, the _insideInferenceLoop flag set at line 1806 and reset at line 1920 should be wrapped in a try/finally to ensure the flag is always reset, matching the pattern used in the chat() method (lines 1126-1139). Wrap the entire body of _streamResult (lines 1807-1920) in a try/finally, moving `this._insideInferenceLoop = false` into the finally block.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 9, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1284

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1284

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1284

hono-agents

npm i https://pkg.pr.new/hono-agents@1284

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1284

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1284

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1284

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1284

commit: 4816708

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