Repository navigation
feat(agents): experimental ThinkHarness, Think's agent loop as a Lifecycle capability - #2512
Conversation
…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 detectedLatest commit: bcaccaf 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 import sizes: 2 entry points changed, no growth
Changed exports (8)
How this worksEach 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 |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
| 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 }); |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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" }; |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
…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).
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.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
| // 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 }; |
There was a problem hiding this comment.
🟥 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.
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.
…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.
Adds
ThinkHarness, Think's agent loop as a Lifecycle capability, at a new experimentalagents/harness/thinkentry 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.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"]recovery: "rerun"; otherwise the call is reported to the model as interrupted.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.recoveryholds onlymaxAttempts,backoffMsandstallTimeoutMs. The wake jobs are flagged for the alarm memory-limit breaker.beforeTurn(before every model call),beforeToolCall(run/block/substitute),afterToolCall,onStepFinish,onChunk,onTurnEnd,onError,classifyError(context overflow → compact and retry once),onRecovery.create,forkandlist, plusregenerate(as a branch),continue,reset,chat(input, callback),inspect, andinFlight. It implements the ChannelsAgentHarnessinterface.ThinkChat: Think'scf_agent_chat_*protocol andGET …/get-messagesfor one session. It covers chat requests, regenerate, cancel, clear, tool results, approvals, and stream resume after a reconnect or an eviction.usermessages and cannot rewrite a stored message by reusing its id; a client tool never replaces a server tool.whenBusy: "steer"throwsSteerNotSupportedErrorinstead of quietly queueing the input.subscribe()reports chunks, messages, operation status, run start and end,reset, andtranscript(deletions or a compaction: re-read).branches(messageId),search(query)andcompact().configureSessionhands over the raw Sessions handle for compaction settings and writes that do not start a turn; those writes still reachsubscribe()andThinkChatclients through the Sessions change feed."", so an existing Think transcript is the root session. Operations and started tool calls live incf_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, andconfigure().Example
examples/next/harnesses/thinkruns ThinkHarness on a plain Durable Object, with the stockuseAgentChatclient. Its three tools cover the three ways a call runs:getWeatherruns on the server and is rerun after an eviction,sendNotificationwaits for approval, andgetUserTimezoneruns in the browser.Think scoreboard
packages/think/src/harness/think.tsis an internalThink extends Agentbuilt onThinkHarness+ThinkChat.pnpm test:harnessruns Think's unchanged workers suite with the test agents'../thinkimport pointed at it and writesharness-compat.md.pnpm test:harness:checkfails 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.