Skip to content

feat(think): tool-call lifecycle hook follow-ups (#1343)#1823

Merged
threepointone merged 2 commits into
mainfrom
think-tool-call-lifecycle-followups
Jun 27, 2026
Merged

feat(think): tool-call lifecycle hook follow-ups (#1343)#1823
threepointone merged 2 commits into
mainfrom
think-tool-call-lifecycle-followups

Conversation

@threepointone

@threepointone threepointone commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Addresses follow-ups 1, 2, 4, and 5 from #1343.

Summary

  • (1) Preserve preliminary streaming through beforeToolCall. Tools whose execute is an async generator (async function* execute(...) — the canonical AI SDK streaming form) now stream their preliminary tool-results to the model even though Think wraps execute to consult beforeToolCall first. The streaming wrapper is itself an async function* that awaits the decision then yield*s every chunk through. Non-streaming tools keep a scalar wrapper, so they never emit a synthetic preliminary chunk (the downside the issue flagged for the "always return AsyncIterable" approach). The non-canonical async () => makeIterator() form (a Promise<AsyncIterable>, which does not stream even in the raw AI SDK) still collapses to its last yielded value. Shared decision logic was extracted into _resolveToolCallDecision.

  • (2) Per-tool typing for output (and a latent input fix). When an explicit TOOLS generic is passed, narrowing on ctx.toolName now narrows ctx.input on beforeToolCall and — new — ctx.output on afterToolCall's success branch to that tool's inferred output type. The AI SDK's DynamicToolCall arm (input: unknown) was previously collapsing the union to unknown, so even input didn't narrow despite the docs' claim; the contexts now distribute over keyof TOOLS (dropping the dynamic arm) when keys are literal. Default ToolSet behavior is unchanged. Added tool-call-types.test-d.ts.

  • (4) needsApproval × beforeToolCall ordering test. Added a raw AI SDK needsApproval tool (not a Think Action) plus tests proving the dual-gate ordering: the approval gate prompts first; after approval, beforeToolCall is still the outer gate (block → execute never runs; allow → runs once).

  • (5) Counter-based "block prevents execute" test. Added an execute invocation counter to the test tool and assertions that block/substitute never run it (direct proof rather than the prior indirect output check).

Follow-up #2's ToolCallDecision.input half and #3 (skip-wrapping optimization) are intentionally not included — see the remaining scope in #1343.

Test plan

  • tsgo typecheck (main + tests tsconfigs)
  • Unit tests: 890 passed
  • E2E tests: 29 passed (4 pre-existing skips)
  • pnpm run build (declaration generation with the new conditional types)
  • oxlint + oxfmt clean

Made with Cursor


Open in Devin Review

Address follow-ups 1, 2, 4, and 5 from #1343:

- Preserve preliminary streaming through `beforeToolCall` for async
  generator `execute` tools, while keeping scalar tools free of synthetic
  `preliminary` chunks. Extract the shared decision logic into
  `_resolveToolCallDecision`.
- Type `ToolCallResultContext.output` (and fix `ToolCallContext.input`)
  per tool when an explicit `TOOLS` generic is passed: narrowing on
  `ctx.toolName` now narrows input/output. Default `ToolSet` behavior is
  unchanged. Add `tool-call-types.test-d.ts`.
- Add a raw `needsApproval` tool + `beforeToolCall` dual-gate ordering test.
- Add an `execute` invocation counter to the test tool and assert
  block/substitute never run it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jun 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c2cfe43

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/think Minor

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

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

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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread packages/think/src/think.ts
@pkg-pr-new

pkg-pr-new Bot commented Jun 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

create-think

npm i https://pkg.pr.new/create-think@1823

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: c2cfe43

…of streaming tools

Document and annotate that blocking or substituting an `async function*`
tool surfaces one extra `preliminary: true` tool-result chunk to
observation hooks (e.g. onChunk). This is inherent: the wrapper must
commit to an AsyncIterable shape synchronously to preserve streaming on
the execute path, and the AI SDK turns every yielded value into a
preliminary + final. The model-visible final output is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone threepointone merged commit b58b5a3 into main Jun 27, 2026
4 checks passed
@threepointone threepointone deleted the think-tool-call-lifecycle-followups branch June 27, 2026 13:20
@github-actions github-actions Bot mentioned this pull request Jun 27, 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.

1 participant