feat(think): host bridge, permissions, sandboxed hook dispatch (Phase 3+4)#1284
feat(think): host bridge, permissions, sandboxed hook dispatch (Phase 3+4)#1284threepointone wants to merge 2 commits intomainfrom
Conversation
…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
|
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| abortSignal?: AbortSignal, | ||
| options?: { continuation?: boolean; parentId?: string } | ||
| ) { | ||
| this._insideInferenceLoop = true; |
There was a problem hiding this comment.
🟡 _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.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
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 thatHostBridgeLoopbackcalls via DO RPC:_hostReadFile,_hostWriteFile,_hostDeleteFile,_hostListFiles(delegate tothis.workspace)_hostGetContext,_hostSetContext(delegate tothis.session)_hostGetMessages(serialized history),_hostSendMessage(routes throughsaveMessages/TurnQueue)_hostGetSessionInfo({ messageCount })Expanded permissions —
ExtensionPermissionsnow includes:context: { read: string[] | "all"; write: string[] | "own" }— per-label context accessmessages: "none" | "read"— conversation history accesssession: { sendMessage: boolean; metadata: boolean }— session capabilitiesExpanded
HostBridgeLoopback— 6 new permission-gated methods:getContext,setContext,getMessages,sendMessage,getSessionInfo. Each has a dedicated permission check with descriptive errors.Re-entrancy guard —
_insideInferenceLoopflag tracks stream consumption in_streamResultandchat()._hostSendMessageroutes throughsaveMessageswhich queues viaTurnQueue— safe during inference (message executes after current turn completes).Phase 4 — Sandboxed hook dispatch
Extension source format — structured
{ tools, hooks }:Hook discovery —
manifest()RPC returns{ hooks: [...] }. Think discovers hooks at load time.getHookSubscribers(hookName)returns extensions in load order.Pipeline dispatch —
_pipelineExtensionBeforeTurnruns after subclassbeforeTurn:TurnContextSnapshot(plain serializable data:system,toolNames,messageCount,continuation,body,modelId)entrypoint.hook("beforeTurn", snapshot)with timeoutTurnConfig(scalars last-wins, providerOptions deep-merge)modelandtoolsskipped — not serializable across RPC. UseactiveToolsinstead.Timeouts —
hookTimeoutproperty (default 5s). Timer properly cleared on success.load_extensiontool — updated description documents{ tools, hooks }format and host capabilities.Test plan
WorkerLoader(structured format, hook discovery, hook invocation, snapshot data flow, error handling, timeout behavior, tools-only extensions)Design decisions
TurnContextSnapshotis a plain object that survives structured clone. We originally plannedRpcTargetproxies with lazy methods, but class instances lose their methods during Workers RPC serialization. Snapshots are simpler and correct.modelortools— these types aren't serializable across the RPC boundary. Extensions control tool availability viaactiveTools(string array). Only subclass hooks can swap the model or add AI SDK tool objects.result ?? {}notresult || {}— prevents falsy coercion when extensions return{ maxSteps: 0 }.Made with Cursor