feat(think): lifecycle hooks, dynamic context, extension manifest#1278
Merged
threepointone merged 4 commits intomainfrom Apr 9, 2026
Merged
feat(think): lifecycle hooks, dynamic context, extension manifest#1278threepointone merged 4 commits intomainfrom
threepointone merged 4 commits intomainfrom
Conversation
…essage Phase 1 of the Think extension system redesign. Think now owns the streamText call end-to-end, enabling lifecycle hooks at every stage of the agentic loop. Users who need full custom inference extend Agent directly instead of overriding onChatMessage. BREAKING CHANGES: - Remove onChatMessage() — Think owns the streamText call internally via private _runInferenceLoop(TurnInput). All 4 entry paths (WebSocket, chat(), saveMessages, auto-continuation) converge on it. - Remove assembleContext() — absorbed by beforeTurn hook. Think assembles context internally; beforeTurn receives the result in TurnContext and can override any part via TurnConfig. - Remove getMaxSteps() method — replaced by maxSteps property (default 10). Per-turn override via TurnConfig.maxSteps. - Deprecate sanitizeMessageForPersistence() — will move to session configuration in a future release. - Deprecate ChatMessageOptions — aliased to TurnInput for migration. NEW LIFECYCLE HOOKS: - beforeTurn(ctx: TurnContext) → TurnConfig | void Fires before streamText. Inspect assembled system prompt, messages, tools, model. Return overrides: model, system, messages, tools, activeTools, toolChoice, maxSteps, providerOptions. - beforeToolCall(ctx: ToolCallContext) → ToolCallDecision | void Fires when model produces a tool call. Currently observation-only (fires via onStepFinish data post-execution). ToolCallDecision is a discriminated union: allow | block | substitute. Block/substitute not yet functional — AI SDK doesn't expose pre-execution interception in Workers runtime. Types are in place for future implementation. - afterToolCall(ctx: ToolCallResultContext) → void Fires after tool execution with tool name, args, and result. - onStepFinish(ctx: StepContext) → void Fires after each step (initial, continue, tool-result) with step type, text, tool calls, tool results, finish reason, usage. - onChunk(ctx: ChunkContext) → void Fires per streaming chunk. High-frequency, observational only. NEW CONFIGURATION: - maxSteps property (replaces getMaxSteps method) - getExtensions() stub for Phase 2 extension declaration - MCP tools auto-merged into tool set (no manual merging needed) - waitForMcpConnections moved inside inference loop NEW EXPORTED TYPES: TurnInput, TurnContext, TurnConfig, ToolCallContext, ToolCallDecision, ToolCallResultContext, StepContext, ChunkContext, ExtensionConfig DESIGN DECISIONS: - _runInferenceLoop is private — hooks always fire, no bypass possible - ToolCallDecision is a discriminated union (allow/block/substitute) with clear, non-overlapping semantics per action - chat() now wraps _runInferenceLoop in agentContext.run() for consistency with the WebSocket path - _transformInferenceResult is a protected test seam for error injection (replaces the old onChatMessage stream-wrapping pattern) TEST CHANGES: - Migrated 6 test agents that overrode onChatMessage or getMaxSteps - TestAssistantAgentAgent: replaced fake stream with mock model - ThinkTestAgent: error injection via _transformInferenceResult, added beforeTurn/onStepFinish/onChunk instrumentation - ThinkProgrammaticTestAgent: captures via beforeTurn instead of onChatMessage - ThinkRecoveryTestAgent/ThinkNonRecoveryTestAgent: count via beforeTurn instead of onChatMessage - LoopToolTestAgent: added tool call hook instrumentation - ThinkToolsTestAgent: switched to tool-calling mock model - Renamed _onChatMessageCallCount → _turnCallCount - Updated stale test descriptions referencing removed methods - Added 11 new hook tests (hooks.test.ts): beforeTurn context, multi-turn, convergence across entry paths, onStepFinish, onChunk, maxSteps property - 191 tests pass across 9 test files Made-with: Cursor
Phase 2 of the Think extension system redesign. Extensions can now
declare context blocks in their manifests, and Session supports
dynamic add/remove of context blocks after initialization.
SESSION CHANGES (packages/agents):
- ContextBlocks.addBlock(config) — register a new context block after
init. Triggers load() if blocks haven't been loaded yet. Initializes
provider, loads content, adds to both configs array and blocks Map.
- ContextBlocks.removeBlock(label) — remove a block. Cleans up from
configs, blocks, and loaded skills tracking. Skill unload callbacks
are NOT fired (appropriate for full extension removal). Caller must
call refreshSystemPrompt() to rebuild the prompt.
- Session.addContext(label, options?) — public API wrapping addBlock.
Auto-wires AgentContextProvider (SQLite-backed) when no provider is
given. Requires builder-constructed sessions (Session.create).
- Session.removeContext(label) — public API wrapping removeBlock.
EXTENSION MANIFEST (packages/think):
- ExtensionManifest.context — array of context block declarations
with label, description, type (readonly/writable/skill/searchable),
and maxTokens. Labels are namespaced as {extName}_{label}.
Type is declared but not yet enforced (all blocks use SQLite
storage until bridge providers are implemented in Phase 4).
- ExtensionManifest.hooks — lifecycle hooks the extension provides.
- ExtensionInfo.contextLabels — namespaced labels in list() output.
BRIDGE PROVIDERS (packages/think — Phase 4 infrastructure):
- ExtensionContextBridge, ExtensionWritableBridge, ExtensionSkillBridge
adapt extension Worker RPC into Session provider interfaces.
Uses protected base fields so children don't duplicate state.
Not wired yet — current blocks use AgentContextProvider directly.
- createBridgeProvider(label, type, entrypoint) factory function.
EXTENSION LIFECYCLE IN THINK:
- _initializeExtensions() — creates ExtensionManager from
extensionLoader property, loads static extensions from
getExtensions(), restores dynamic extensions from DO storage,
registers extension context blocks in Session via addContext()
(SQLite-backed, not bridge-delegated), wires onUnload callback.
- extensionLoader property — set to env.LOADER to enable extensions.
- extensionManager field — public, auto-created when extensionLoader
is set. Use for dynamic load()/unload() at runtime.
- Extension tools auto-merged in _runInferenceLoop.
- ExtensionManager.unload() fires onUnload callback which removes
context blocks from Session and refreshes the system prompt.
- ExtensionManager.onUnload(cb) — register cleanup callback.
- ExtensionManager.getContextLabels() — namespaced labels.
- ExtensionManager.getManifest(name) — get manifest by name.
HIBERNATION RESTORATION:
onStart() ordering:
1. Workspace initialization
2. configureSession() (builder phase)
3. ExtensionManager created (if extensionLoader set)
4. getExtensions() loaded (static extensions)
5. restore() (dynamic extensions from DO storage)
6. Extension context blocks registered in Session (mutation phase)
7. Protocol handlers
8. User's onStart()
TESTS:
5 new tests for dynamic context:
- addContext registers a new block
- addContext block appears in system prompt after refresh
- removeContext removes the block
- removeContext returns false for non-existent block
- removed block disappears from system prompt after refresh
195 total tests pass across 8 files.
Made-with: Cursor
README: - Replace "Override points" table with "Configuration" + "Lifecycle hooks" tables reflecting the new API (no onChatMessage, assembleContext, getMaxSteps) - Add beforeTurn example with TurnConfig documentation - Add "Dynamic context blocks" section showing addContext/removeContext - Update MCP section to note auto-merging - Update production features list with lifecycle hooks Assistant example: - Replace getMaxSteps() with maxSteps property - Remove manual MCP tool merging in getTools() (auto-merged now) - Add beforeTurn and onChatResponse hooks to demonstrate the lifecycle - Import new types (TurnContext, TurnConfig, ChatResponseResult) Changeset: - Patch for @cloudflare/think and agents - Documents breaking changes (removed onChatMessage, assembleContext, getMaxSteps) and new features (hooks, maxSteps property, MCP auto-merge, dynamic context blocks, expanded manifest) Made-with: Cursor
Add 5 more tests for dynamic context blocks: - dynamic block is writable by default (AgentContextProvider) - dynamic block content can be written via setContextBlock - session tools include set_context after adding writable block - addContext coexists with configureSession blocks (both in prompt) - dynamic block visible in chat turn tools (negative: ThinkTestAgent has no context blocks, so set_context should not appear) Also add test helpers to ThinkSessionTestAgent: - getSessionToolNames() — returns tool names from session.tools() - getContextBlockDetails() — returns writable/isSkill for a block 200 total tests pass across 8 files. Made-with: Cursor
🦋 Changeset detectedLatest commit: eddb62d The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Think now owns the inference loop end-to-end. Instead of overriding
onChatMessage()for full control, developers use lifecycle hooks that fire at every stage of the agentic loop. Session gains dynamic context blocks for runtime context management. The extension manifest is expanded with context block declarations and hook support.Phase 1 — Own the inference loop
onChatMessage(),assembleContext(),getMaxSteps()— Think ownsstreamTextinternally via private_runInferenceLoop(TurnInput). All 4 entry paths (WebSocket,chat(),saveMessages, auto-continuation) converge on it.beforeTurn(ctx)— inspect assembled system prompt, messages, tools, model. ReturnTurnConfigto override any part (model, system, messages, tools, activeTools, toolChoice, maxSteps, providerOptions).beforeToolCall(ctx)— fires when model calls a tool (observation only for now).afterToolCall(ctx)— fires after tool execution with result.onStepFinish(ctx)— per-step callback.onChunk(ctx)— per-chunk callback (streaming analytics).maxStepsis now a property (default 10), not a method. Per-turn override viaTurnConfig.maxSteps.this.mcp.getAITools().ToolCallDecisionis a discriminated union:allow|block|substitute.Phase 2 — Dynamic context + extension manifest
Session.addContext(label, options?)— register context blocks after session initialization.Session.removeContext(label)— remove dynamically added blocks.ContextBlocks.addBlock()/removeBlock()— underlying primitives.ExtensionManifestexpanded with:context— namespaced context block declarations ({extName}_{label})hooks— lifecycle hooks the extension providesExtensionContextBridge,ExtensionSkillBridge,ExtensionWritableBridge) — Phase 4 infrastructure for extension Worker RPC delegation._initializeExtensions()in Think — createsExtensionManager, loads static + restored extensions, registers context blocks in Session, wires unload cleanup.extensionLoaderproperty +extensionManagerfield on Think.Docs + example
beforeTurnexample,TurnConfigdocs, dynamic context section.maxStepsproperty, removed manual MCP merging, addedbeforeTurn/onChatResponsehooks.Breaking changes
onChatMessage()removed — use lifecycle hooks. Full-custom inference: extendAgentdirectly.assembleContext()removed — absorbed bybeforeTurn. Think assembles internally; hook sees defaults and can override.getMaxSteps()method removed — usemaxStepsproperty.ChatMessageOptionsdeprecated — aliased toTurnInput.These are expected for an
@experimental0.xpackage. No other packages in the repo are affected — all examples usingAIChatAgentare untouched.Test plan
agentsandthinkpackagesagentspackage changes are purely additive (new methods only)Known limitations
beforeToolCallblocking/substitution is not yet functional — the AI SDK'sstreamTextdoes not expose a pre-execution interception point in the Workers runtime. The hook fires post-execution (observation only). Types (ToolCallDecisionwithblock/substitute) are in place for future implementation.context.typeis declared but not enforced — all blocks use SQLite-backed storage until bridge providers are wired (Phase 4).Made with Cursor