Skip to content

feat(agents): experimental ThinkHarness, Think's agent loop as a Lifecycle capability - #2512

Merged
mattzcarey merged 12 commits into
mainfrom
feat/think-harness
Oct 6, 2026
Merged

mattzcarey merged 12 commits into
mainfrom
feat/think-harness

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Adds ThinkHarness, Think's agent loop as a Lifecycle capability, at a new experimental agents/harness/think entry point. It has PiHarness's shape and uses Think's durability patterns on the Agents SDK's own pieces: a Sessions capability for transcripts and a Streams capability for in-flight output, both owned by the harness, and a Lifecycle job per session. No Tasks, no driver. A host installs the harness and nothing else.

export class Assistant extends DurableObject<Env> {
  harness = new ThinkHarness({
    model: createAI({ binding: this.env.AI })("@cf/moonshotai/kimi-k2.7-code"),
    tools: {
      weather: { ...tool({ description, inputSchema, execute }), recovery: "rerun" }
    }
  });
  webSockets = new WebSockets();
  chat = new ThinkChat({ harness: this.harness, webSockets: this.webSockets }); // useAgentChat
  lifecycle = Lifecycle.install(this)
    .use(this.harness)
    .use(this.webSockets).use(this.chat);
}

const { text } = await this.harness.prompt("Hello");
const receipt = await this.harness.session(id).submit(input, { operationId }); // idempotent

How a turn runs

Each step reads only durable state, so after an eviction the next wake continues from the last write.

flowchart TD
  submit["submit(): op row + wake job"] --> wake["wake job (singleflight, heartbeat, recoveryLoop)"]
  wake --> next{"nextAction(op, assistant message)"}
  next -->|"no reply yet / tool results in"| model["one model call → chunks to a Stream"]
  model -->|"cutover: persist message + discard stream, one txn"| next
  next -->|"server tool calls"| tools["harness runs each call, recorded first"]
  tools --> next
  next -->|"approval / client tool"| await["settle op; the answer is a new op that continues the message"]
  next -->|"no tool calls"| done["settle op"]
  model -.->|"eviction"| rec["rebuild partial from the Stream, keep it, call the model again"]
  tools -.->|"eviction"| pol["per-tool policy: report as interrupted (default) or rerun"]
Loading
  • The model only sees tool definitions. The harness runs each server tool call itself, so it knows exactly which call an eviction cut short. A tool opts into being rerun with recovery: "rerun"; otherwise the call is reported to the model as interrupted.
  • Memory limits: a sealed alarm memory-limit breaker marks the running operation; the next start settles it unanswered (out_of_memory) with its partial answer kept, and never reruns it. A restart keeps a wake already set for later, so recovery backoff survives a deploy.
  • Recovery budget: recovery holds only maxAttempts, backoffMs and stallTimeoutMs. The wake jobs are flagged for the alarm memory-limit breaker.
  • Hooks: beforeTurn (before every model call), beforeToolCall (run/block/substitute), afterToolCall, onStepFinish, onChunk, onTurnEnd, onError, classifyError (context overflow → compact and retry once), onRecovery.
  • Sessions: create, fork and list, plus regenerate (as a branch), continue, reset, chat(input, callback), inspect, and inFlight. It implements the Channels AgentHarness interface.
  • ThinkChat: Think's cf_agent_chat_* protocol and GET …/get-messages for one session. It covers chat requests, regenerate, cancel, clear, tool results, approvals, and stream resume after a reconnect or an eviction.
  • Untrusted input: client input may only add user messages and cannot rewrite a stored message by reusing its id; a client tool never replaces a server tool.
  • Steering: whenBusy: "steer" throws SteerNotSupportedError instead of quietly queueing the input.
  • Events: subscribe() reports chunks, messages, operation status, run start and end, reset, and transcript (deletions or a compaction: re-read).
  • Transcript: each session has branches(messageId), search(query) and compact(). configureSession hands over the raw Sessions handle for compaction settings and writes that do not start a turn; those writes still reach subscribe() and ThinkChat clients through the Sessions change feed.
  • Storage: the root session is "", so an existing Think transcript is the root session. Operations and started tool calls live in cf_think_harness_* tables.

Not yet supported, listed in docs/agents/harnesses/think.md: concurrency strategies other than queueing, context blocks, truncation and media eviction, structured output, actions, agent tools, skills, extensions, MCP tools, messengers, scheduled tasks, workflows, and configure().

Example

examples/next/harnesses/think runs ThinkHarness on a plain Durable Object, with the stock useAgentChat client. Its three tools cover the three ways a call runs: getWeather runs on the server and is rerun after an eviction, sendNotification waits for approval, and getUserTimezone runs in the browser.

Think scoreboard

packages/think/src/harness/think.ts is an internal Think extends Agent built on ThinkHarness + ThinkChat. pnpm test:harness runs Think's unchanged workers suite with the test agents' ../think import pointed at it and writes harness-compat.md. pnpm test:harness:check fails when a recorded pass regresses. The runner and resolver come from #2396.

Today: 193 of the 991 tests that construct Think pass (427 of all 1225). The run is repeated against a Think that throws when constructed, so tests of modules Think merely uses are left out of the headline. The biggest gaps are tests that reach Think internals (submission and recovery tables, _resumableStream), agent tools, actions, and recovery incidents.

Replaces #2396.

…Lifecycle capability

Sessions for transcripts, Streams for in-flight model output, and one
Lifecycle wake job per session. The harness runs each server tool call
itself, with a per-tool recovery policy. ThinkChat serves a session over
Think's useAgentChat WebSocket protocol.
pnpm test:harness runs Think's workers suite with the test agents' ../think
import pointed at an internal Think built on agents/harness/think, and
records the score in harness-compat.md (427 of 1225 today).
test:harness:check fails when a recorded pass regresses. The compat runner
and resolver come from #2396.
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bcaccaf

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

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

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

@agent-think

agent-think Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 2 entry points changed, no growth

Entry point Exports Largest gzip change Size now
🆕 agents/harness/ai-sdk 1 new — 112.8 KiB
🆕 agents/harness/think 7 new — 179.9 KiB
Changed exports (8)
Import Gzip change Size now
🆕 agents/harness/think#ThinkHarness — 179.9 KiB
🆕 agents/harness/ai-sdk#toResponseChunk — 112.8 KiB
🆕 agents/harness/think#ThinkChat — 3.1 KiB
🆕 agents/harness/think#ThinkSession — 1.1 KiB
🆕 agents/harness/think#ThinkSessions — 422 B
🆕 agents/harness/think#classifyContextOverflow — 373 B
🆕 agents/harness/think#SteerNotSupportedError — 291 B
🆕 agents/harness/think#ROOT_SESSION — 151 B
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 92d1770d → bcaccaf1 · workflow run · reported by agent-think[bot]

@pkg-pr-new

pkg-pr-new Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: bcaccaf

@mattzcarey
mattzcarey marked this pull request as ready for review October 6, 2026 19:06

@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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 10 potential issues.

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.

🔍 Coordinate the upstream documentation update

The docs rules require a matching cloudflare-docs update for new reference pages. Coordinate that separate update before publishing this API.

Devin Review


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

Comment thread packages/agents/src/harness/think/harness.ts Outdated
Comment thread packages/agents/src/harness/think/chat.ts
Comment on lines +1111 to +1120
live.partial.push(chunk);
segment.push(chunk);
segmentBytes += JSON.stringify(chunk).length;
if (
segment.length >= SEGMENT_CHUNKS ||
segmentBytes >= SEGMENT_MAX_BYTES
) {
flush();
}
this.#events.emit(session, { type: "chunk", operationId, chunk });

@devin-ai-integration devin-ai-integration Bot Oct 6, 2026 •

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.

🔴 Early model output disappears after eviction

If eviction occurs before ten small chunks arrive, flush never persists their segment. Recovery finds an empty stream and drops output already sent to clients.

Learn more

The model chunks are broadcast immediately but held in an in-memory segment until ten chunks or 256,000 characters accumulate. #recoverModel rebuilds only chunks already present in Streams. When eviction ends the isolate before the segment reaches either threshold, those chunks cannot be rebuilt, even though connected clients received them.

Example: A model streams start, text-start, and text-delta containing Hello, then the object is evicted. Recovery reads zero stored chunks and the assistant's Hello disappears from the persisted answer.

Recommended fix: Make each emitted chunk durable before broadcasting it, or document and implement a recovery protocol that can safely handle losing the buffered tail without presenting it as durable output.

Devin Review


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

Comment on lines +108 to +113
if (runnable.length > 0) return { _tag: "run-tools", calls: runnable };
if (awaiting) return { _tag: "await-input" };
if (progress.pendingModel && progress.steps < maxSteps) {
return { _tag: "call-model" };
}
return { _tag: "end" };

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.

🟡 Step limit strands completed tool calls

When the last allowed model call requests a server tool, nextAction runs it despite the exhausted step budget. The operation then completes without a model reply, leaving the tool result as its final answer.

Learn more

A model call consumes one step before its tool calls are processed. The action selector runs tools before checking the step limit. Once those tools finish, pendingModel remains true, but the limit prevents the follow-up model call; the operation settles done with the assistant message containing only tool calls. This affects any turn whose last permitted model call requests a server tool.

Example: Set maxSteps: 1 and ask the model to call weather. The tool executes, then prompt() returns done with empty text rather than a model answer incorporating the weather.

Recommended fix: Define whether maxSteps counts tool-follow-up calls or complete model/tool cycles. Enforce the budget before starting a call that cannot be followed up, or return an explicit incomplete outcome instead of completing the operation as answered.

Devin Review


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

Comment thread packages/agents/src/harness/think/chat.ts
Comment thread packages/think/scripts/harness-compat.ts
Comment thread packages/agents/src/harness/think/harness.ts Outdated
Comment thread packages/agents/src/harness/think/chat.ts
Comment thread packages/agents/src/harness/think/chat.ts
…n recovery

The harness creates its in-flight Streams internally (only it reads them),
so hosts install Sessions and the harness only. A tool's interrupted-call
policy moves from recovery.tools to an optional recovery field on the tool;
recovery keeps only the budget (maxAttempts, backoffMs, stallTimeoutMs).
devin-ai-integration[bot]

This comment was marked as resolved.

Think's agent loop on a plain Durable Object with ThinkHarness, served to
the stock useAgentChat by ThinkChat. Three tools show the three ways a
call runs: on the server and rerun after an eviction, on the server after
approval, and in the browser.
devin-ai-integration[bot]

This comment was marked as resolved.

The harness writes every message and its operations reference them, so it
owns the Sessions capability as it does Streams; hosts install the harness
only. session.transcript is the Session handle for branches, search,
compaction and direct writes, and direct writes reach subscribe() and
ThinkChat clients through the Sessions change feed. reservedMetadataKeys
passes through to the transcript.

@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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +1633 to +1647
switch (change.type) {
case "append":
case "update":
case "import":
this.#events.emit(change.sessionId, {
type: "message",
message: asUIMessage(change.message)
});
return;
case "clear":
this.#events.emit(change.sessionId, { type: "reset" });
return;
default:
return;
}

@devin-ai-integration devin-ai-integration Bot Oct 6, 2026 •

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.

🔴 Transcript edits leave live views stale

Deleting or compacting through session.transcript produces no harness event. ThinkChat clients keep the old transcript, and harness-backed Think keeps stale messages until another sync.

Learn more

The transcript handle exposes deleteMessages(), compact(), and addCompaction(), and each emits a Sessions change event. The new bridge forwards append, update, import, and clear but silently drops delete, compact, and compaction. ThinkChat refreshes connected clients only for message and reset events. The harness-backed Think cache also re-reads only for those events, leaving its synchronous messages getter outdated.

Example: After a user deletes an active message through session.transcript.deleteMessages([id]), session.messages() reflects the deletion, but a connected chat client and Think.messages still display it.

Recommended fix: Extend the harness event vocabulary with an explicit transcript-changed event and have ThinkChat and the Think cache refresh for delete and compaction changes. Avoid representing these changes as individual message appends, since a compaction changes the active path rather than one stored row.

Devin Review


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

Comment thread packages/agents/src/harness/think/harness.ts Outdated
The agents workspace link brought a second React instance into the bundle,
so useAgent's hooks read a null dispatcher and the page crashed on load.
…e internal records

ThinkSession gets branches(messageId), search(query) and compact(), and
the session.transcript getter goes; configureSession still hands over the
raw Sessions handle. The internal operation tables are OperationRecords in
records.ts, so they do not share a name with agents/harness/store.
- onStart keeps a wake already set for later (recovery backoff, the alarm
  memory-limit breaker's delay) instead of replacing it with one due now;
  a sealed breaker settles the running operation as out_of_memory.
- Streamed chunks wait at most 100ms in memory before they are written,
  so output a client saw survives an eviction.
- Client input may only add user messages and cannot rewrite a stored
  message by reusing its id; a client tool cannot replace a server tool.
- ThinkChat sends one start and one finish per operation, so a multi-step
  turn is one message for every client, and answers a STREAM_PENDING
  probe with STREAM_RESUME_NONE when the work ends without streaming.
- Streams rechecks the v1 legacy table before use, so a second Streams
  instance dropping it cannot break the harness's appends.
- Example: one chat per browser, busy while a message is submitted,
  themed error blocks, and a composition snippet in the README.
- Scoreboard: the headline counts only tests that construct Think.

@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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 2 new potential issues.

Devin Review

Comment thread packages/agents/src/harness/think/harness.ts Outdated
Comment on lines +1773 to +1782
// A client cannot replace a server tool: the server's definition, its
// execute and its approval policy win on a name clash.
for (const name of Object.keys(client)) {
if (Object.hasOwn(tools, name)) {
console.warn(
`ThinkHarness ignored a client tool named "${name}": a server tool has that name`
);
}
}
return { ...client, ...tools };

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.

🟥 Client tool reply can invoke server tools

If a pending client tool shares a name with a server tool, #tools reclassifies its call as server-owned during continuation. The reply can then trigger server execution without the expected server-tool approval path.

Devin Review


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

- Deletions and compactions on a session's transcript emit a new
  transcript event; ThinkChat re-sends the transcript instead of clearing
  the chat, and compact() no longer reports itself as a reset.
- The change-feed bridge skips the harness's own writes by message id, so
  a direct write landing during a harness commit still reaches listeners.
- A sealed memory-limit breaker only marks running operations; the next
  drive settles them unanswered (out_of_memory) through the usual path,
  keeping the partial answer and notifying clients, and never reruns them.
- A client tool result answers only a client tool (not_client_tool
  otherwise), so a server tool's result comes from running it.
devin-ai-integration[bot]

This comment was marked as resolved.

…don_reason

CREATE TABLE IF NOT EXISTS leaves an existing table as it was, so a
sealed memory-limit breaker failed to mark running operations on objects
whose table was created before the column.
@mattzcarey
mattzcarey merged commit bda70b5 into main Oct 6, 2026
17 checks passed
@mattzcarey
mattzcarey deleted the feat/think-harness branch October 6, 2026 23:36
@github-actions github-actions Bot mentioned this pull request Oct 6, 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