Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legacy-workspaces-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": minor
---

Extract the existing Shell storage, snapshot Bash, and codemode state behavior into `@cloudflare/think/workspace-shell-legacy`. Think now consumes a narrow filesystem and runtime contract while the legacy workspace remains the default.
3 changes: 2 additions & 1 deletion examples/agent-skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The agent has:

```ts
import { Think, skills } from "@cloudflare/think";
import { createWorkspaceOperations } from "@cloudflare/think/tools/workspace";
import bundledSkills from "agents:skills"; // -> ./skills next to this file

export class SkillsAgent extends Think<Env> {
Expand All @@ -50,7 +51,7 @@ export class SkillsAgent extends Think<Env> {
getSkillScriptRunner() {
return skills.runner({
loader: this.env.LOADER,
workspaceInstance: this.workspace
workspaceInstance: createWorkspaceOperations(this.workspace)
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion examples/agent-skills/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createWorkspaceOperations } from "@cloudflare/think/tools/workspace";
import { callable, routeAgentRequest } from "agents";
import { Think, skills } from "@cloudflare/think";
import bundledSkills from "agents:skills";
Expand All @@ -22,7 +23,7 @@ export class SkillsAgent extends Think<Env> {
getSkillScriptRunner() {
return skills.runner({
loader: this.env.LOADER,
workspaceInstance: this.workspace
workspaceInstance: createWorkspaceOperations(this.workspace)
});
}

Expand Down
35 changes: 18 additions & 17 deletions examples/assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ the sub-agent routing primitive from `agents`.
- **Shared workspace across chats** — `AssistantDirectory` owns one `Workspace`
backed by its SQLite; every `MyAssistant` child gets a `SharedWorkspace`
proxy that forwards file I/O to the parent. A `hello.txt` written in chat A
is visible verbatim in chat B. The proxy swaps in via the `WorkspaceFsLike`
type exported by `@cloudflare/shell` — no casts; builtin workspace tools
AND codemode's `state.*` sandbox API both route through it
is visible verbatim in chat B. The owner explicitly selects
`workspace-shell-legacy`; the proxy provides Think's Computer-shaped
`fs`/`runtime` contract and the legacy codemode `state.*` capability
- **Shared MCP across chats** — server registry, OAuth credentials, live
connections, and tool descriptors all live on `AssistantDirectory`. Auth
to a server once (e.g. GitHub MCP) and every chat sees its tools. Each
Expand Down Expand Up @@ -159,7 +159,7 @@ a DO RPC hop:

```ts
class MyAssistant extends Think<Env> {
override workspace: WorkspaceFsLike = new SharedWorkspace(this);
override workspace = new SharedWorkspace(this);

getTools() {
return {
Expand All @@ -175,7 +175,14 @@ class MyAssistant extends Think<Env> {
}
}

class SharedWorkspace implements WorkspaceFsLike {
class SharedWorkspace implements WorkspaceFsLike, WorkspaceStateProvider {
readonly fs = new LegacyShellFilesystem(this);
readonly runtime = new LegacyShellRuntime(this);

[workspaceStateProvider](ctx) {
return createLegacyWorkspaceStateConnectors(this, ctx);
}

readFile(p) {
return (await this.parent()).readFile(p);
}
Expand All @@ -187,18 +194,12 @@ class SharedWorkspace implements WorkspaceFsLike {
}
```

The proxy satisfies `@cloudflare/shell`'s `WorkspaceFsLike` interface,
which is a strict superset of `@cloudflare/think`'s `WorkspaceLike`.
That one type annotation unlocks two things at once:

- **All of Think's workspace-aware machinery** (`createWorkspaceTools`,
lifecycle hooks, the builtin `listWorkspaceFiles` /
`readWorkspaceFile` RPCs) works unchanged against the proxy.
- **Codemode's `state.*` sandbox API** works too, via
`createWorkspaceStateBackend(this.workspace)`. Multi-file operations
like `state.planEdits` and `state.applyEdits` run against the shared
workspace, so a plan composed in one chat can mutate files another
chat just created.
The proxy's `fs` and `runtime` facades satisfy Think's native
`ThinkWorkspace` contract. Its explicit `workspaceStateProvider` preserves the
legacy codemode `state.*` API through `createLegacyWorkspaceStateConnectors()`.
Multi-file operations like `state.planEdits` and `state.applyEdits` therefore
run against the shared workspace, so a plan composed in one chat can mutate
files another chat just created.

The parent DO and the child facet live on the same machine, so each
RPC hop is in-process and cheap (no network, no serialization across
Expand Down
16 changes: 9 additions & 7 deletions examples/assistant/agents/assistant/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { callable } from "agents";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Think, Workspace } from "@cloudflare/think";
import { Think } from "@cloudflare/think";
import { Workspace } from "@cloudflare/think/workspace-shell-legacy";
import type { ThinkScheduledTasks } from "@cloudflare/think";
import type { FileInfo, WorkspaceChangeEvent } from "@cloudflare/shell";
import { nanoid } from "nanoid";
Expand Down Expand Up @@ -290,15 +291,16 @@ export class AssistantDirectory extends Think<Env, DirectoryState> {
// another chat's files via the sidebar websocket; workspace I/O is
// LLM-tool-only. DO-to-DO RPC doesn't need the decorator.
//
// The surface covers the full `WorkspaceFsLike` interface from
// `@cloudflare/shell`, which is what `createWorkspaceStateBackend`
// needs to drive codemode's `state.*` sandbox API. That means a
// plan from one chat can edit files the same way as a single-chat
// app — the shared workspace is the single source of truth.
// The surface covers the full legacy `WorkspaceFsLike` interface used by
// `createLegacyWorkspaceStateConnectors` to drive codemode's richer
// `state.*` sandbox API. That means a plan from one chat can edit files the
// same way as a single-chat app — the shared workspace is the single source
// of truth.
//
// Each method is a one-line delegate. We use
// `Parameters<Workspace["method"]>[n]` to stay automatically in
// sync with `@cloudflare/shell` rather than re-stating the types.
// sync with the explicitly selected legacy workspace rather than re-stating
// the types.

async readFile(path: string): Promise<string | null> {
return this.workspace.readFile(path);
Expand Down
23 changes: 11 additions & 12 deletions examples/assistant/agents/assistant/agents/my-assistant/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
defaultContextOverflowClassifier
} from "@cloudflare/think";
import bundledSkills from "agents:skills";
import type { WorkspaceFsLike } from "@cloudflare/shell";
import { createExecuteTool } from "@cloudflare/think/tools/execute";
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
import {
createWorkspaceOperations,
createWorkspaceTools
} from "@cloudflare/think/tools/workspace";
import { createExtensionTools } from "@cloudflare/think/tools/extensions";
import { createQuickActionTools } from "@cloudflare/think/tools/browser";
import { createCompactFunction } from "agents/experimental/memory/utils";
Expand Down Expand Up @@ -67,19 +69,16 @@ export class MyAssistant extends Think<Env> {
* init check, the shared proxy is already in place — Think never
* creates a per-chat `Workspace` at all.
*
* Declared as `WorkspaceFsLike` (the wider interface from
* `@cloudflare/shell`) rather than Think's `WorkspaceLike` so that
* `createWorkspaceStateBackend(this.workspace)` in `getTools()` sees
* the full filesystem surface it needs. `WorkspaceFsLike` is a strict
* superset of `WorkspaceLike`, so Think's internals keep working.
* `SharedWorkspace` exposes the Computer-shaped `fs` and `runtime`
* properties that Think uses. Its legacy state provider also keeps the
* codemode `state.*` API connected to the shared filesystem.
*
* All workspace-aware code — the builtin tools from
* `createWorkspaceTools`, lifecycle hooks, the `listWorkspaceFiles`
* / `readWorkspaceFile` RPCs below, and codemode's `state.*` sandbox
* API via `createWorkspaceStateBackend` — routes through this proxy
* transparently.
* API — routes through this proxy.
*/
override workspace: WorkspaceFsLike = new SharedWorkspace(() =>
override workspace = new SharedWorkspace(() =>
this.parentAgent(AssistantDirectory)
);

Expand Down Expand Up @@ -135,7 +134,7 @@ export class MyAssistant extends Think<Env> {
getSkillScriptRunner() {
return skills.runner({
loader: this.env.LOADER,
workspaceInstance: this.workspace
workspaceInstance: createWorkspaceOperations(this.workspace)
});
}

Expand Down Expand Up @@ -191,7 +190,7 @@ When you learn something about the user or their project, save it to memory.`
// Agent one-liner with overrides: the executor comes from
// `env.LOADER`, `cdp.*` from `env.BROWSER` (if bound), and `state.*`
// inside the sandbox is backed by the SHARED workspace — the
// `SharedWorkspace` proxy satisfies `WorkspaceFsLike`, so
// `SharedWorkspace` supplies the legacy state provider, so
// `state.planEdits`/`applyEdits` in chat B sees and mutates the same
// files chat A just wrote. This also assigns `this.codemode`, which
// powers the built-in `approveExecution` / `rejectExecution` /
Expand Down
35 changes: 25 additions & 10 deletions examples/assistant/agents/assistant/shared-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import type { Workspace } from "@cloudflare/think";
import {
workspaceStateProvider,
type WorkspaceStateProvider
} from "@cloudflare/think";
import {
LegacyShellFilesystem,
LegacyShellRuntime,
createLegacyWorkspaceStateConnectors,
type Workspace
} from "@cloudflare/think/workspace-shell-legacy";
import type { WorkspaceFsLike } from "@cloudflare/shell";
import type { AssistantDirectory } from "./agent";

// ── SharedWorkspace — proxy used by children ─────────────────────────
//
// Satisfies `WorkspaceFsLike` (the interface shipped by
// `@cloudflare/shell`) by forwarding every call to the parent
// `AssistantDirectory`'s real `Workspace`. Because `WorkspaceFsLike`
// is a strict superset of `WorkspaceLike`, this also satisfies
// everything Think's builtin tools need — but covering the wider
// surface is what lets us pass the same object to
// `createWorkspaceStateBackend`, so codemode's `state.*` sandbox API
// operates on the shared workspace too.
// Satisfies the legacy `WorkspaceFsLike` interface by forwarding every call
// to the parent `AssistantDirectory`'s real legacy workspace. The Computer-
// shaped `fs` and `runtime` facades satisfy Think's contract, while
// `createLegacyWorkspaceStateConnectors` keeps codemode's richer `state.*`
// sandbox API operating on the same shared files.
//
// Per-call it's one extra RPC hop; parent and child are DO facets
// colocated on the same machine, so the hop is in-process and cheap.
Expand All @@ -21,9 +27,18 @@ import type { AssistantDirectory } from "./agent";
// so caching the resolved stub across the child's lifetime is safe
// even if the parent hibernates and comes back between calls.

export class SharedWorkspace implements WorkspaceFsLike {
export class SharedWorkspace
implements WorkspaceFsLike, WorkspaceStateProvider
{
#stubPromise?: Promise<DurableObjectStub<AssistantDirectory>>;

readonly fs = new LegacyShellFilesystem(this);
readonly runtime = new LegacyShellRuntime(this);

[workspaceStateProvider](ctx: DurableObjectState | ExecutionContext) {
return createLegacyWorkspaceStateConnectors(this, ctx);
}

constructor(
private getParent: () => Promise<DurableObjectStub<AssistantDirectory>>
) {}
Expand Down
4 changes: 4 additions & 0 deletions packages/think/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@
"types": "./dist/react.d.ts",
"import": "./dist/react.js"
},
"./workspace-shell-legacy": {
"types": "./dist/workspace-shell-legacy.d.ts",
"import": "./dist/workspace-shell-legacy.js"
},
"./tools/workspace": {
"types": "./dist/tools/workspace.d.ts",
"import": "./dist/tools/workspace.js"
Expand Down
1 change: 1 addition & 0 deletions packages/think/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ async function main() {
"src/server-entry.ts",
"src/messengers/index.ts",
"src/messengers/telegram.ts",
"src/workspace-shell-legacy.ts",
"src/tools/workspace.ts",
"src/tools/fetch.ts",
"src/tools/execute.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/think/src/cli/init.ts

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.

🔴 Newly scaffolded projects fail their own checks because the generated agent file is only half-migrated

The generated starter agent still hands the raw workspace to the skill script runner (workspaceInstance: this.workspace at packages/think/src/cli/init.ts:433) even though the new adapter is imported at the top of the same generated file, so every freshly scaffolded project ships with an unused import and a value the runner's interface does not accept.
Impact: Users who scaffold a new project get code that fails the project's own lint/typecheck (npm run check) out of the box.

Incomplete mechanical migration of the skills runner call

The PR migrated every other call site to createWorkspaceOperations(this.workspace) (examples/agent-skills/src/server.ts:26, think-starters/coding-agent/agents/coder/agent.ts:50, think-starters/customer-support/agents/support/agent.ts:51), but the CLI template at packages/think/src/cli/init.ts:411 only added the import while line 433 still passes this.workspace.

Think.workspace is now typed ThinkWorkspace (packages/think/src/think.ts:2880), which exposes only fs/runtime and does not structurally satisfy SkillWorkspace (readFile/writeFile/readDir/glob/stat, see packages/agents/src/skills/runner.ts:18-24). The generated project's typecheck script therefore fails, and oxlint flags the unused createWorkspaceOperations import (no-unused-vars is an error per AGENTS.md), so the generated check script fails.

(Refers to line 433)

Open in Devin Review

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

Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ function viteConfig(routePrefix: string | undefined): string {
function agentSource(): string {
return [
`import { Think, skills } from "@cloudflare/think";`,
`import { createWorkspaceOperations } from "@cloudflare/think/tools/workspace";`,
`import bundledSkills from "agents:skills";`,
"",
"export class Assistant extends Think<Env> {",
Expand Down
19 changes: 13 additions & 6 deletions packages/think/src/tests/agents/assistant-tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Agent } from "agents";
import { Workspace } from "@cloudflare/shell";
import type { ToolSet } from "ai";
import { Workspace, type BashToolOptions } from "../../workspace-shell-legacy";
import { createWorkspaceTools } from "../../tools/workspace";
import type { WorkspaceToolsOptions } from "../../tools/workspace";
import { workspaceToolProvider } from "../../workspace";

export class TestAssistantToolsAgent extends Agent {
workspace = new Workspace({
Expand All @@ -10,7 +11,10 @@ export class TestAssistantToolsAgent extends Agent {
});

private getTools() {
return createWorkspaceTools(this.workspace);
return {
...createWorkspaceTools(this.workspace),
...this.workspace[workspaceToolProvider]()
};
}

// Seed workspace with files for testing
Expand Down Expand Up @@ -163,10 +167,13 @@ export class TestAssistantToolsAgent extends Agent {
async toolBash(
script: string,
cwd?: string,
options?: Exclude<WorkspaceToolsOptions["bash"], boolean>
options?: Omit<BashToolOptions, "ops">
): Promise<unknown> {
const tools = options
? createWorkspaceTools(this.workspace, { bash: options })
const tools: ToolSet = options
? {
...createWorkspaceTools(this.workspace),
...this.workspace[workspaceToolProvider]({ legacyBash: options })
}
: this.getTools();
const bash = tools.bash;
if (!bash?.execute) throw new Error("bash tool is not available");
Expand Down
7 changes: 5 additions & 2 deletions packages/think/src/tests/agents/extension-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export class ThinkExtensionHookAgent extends Think {

async listExtLogFiles(): Promise<string[]> {
try {
const entries = await this.workspace.readDir("ext-log");
const entries = await this.workspace.fs.readdir("/ext-log");
return entries.map((e: { name: string }) => e.name);
} catch {
return [];
Expand All @@ -191,7 +191,10 @@ export class ThinkExtensionHookAgent extends Think {

async readExtLogFile(name: string): Promise<unknown | null> {
try {
const content = await this.workspace.readFile(`ext-log/${name}`);
const content = await this.workspace.fs.readFile(
`/ext-log/${name}`,
"utf8"
);
if (content == null) return null;
return JSON.parse(content);
} catch {
Expand Down
25 changes: 16 additions & 9 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,18 +1311,13 @@ export class ThinkTestAgent extends Think {
async seedWorkspaceBytes(
path: string,
bytes: number[],
mimeType?: string
_mimeType?: string
): Promise<void> {
const parent = path.replace(/\/[^/]+$/, "");
const workspace = this.workspace;
const writeFileBytes = Reflect.get(workspace, "writeFileBytes");
if (typeof writeFileBytes !== "function") {
throw new Error("Test workspace does not support writeFileBytes");
}
if (parent && parent !== "/") {
await workspace.mkdir(parent, { recursive: true });
await this.workspace.fs.mkdir(parent, { recursive: true });
}
await writeFileBytes.call(workspace, path, new Uint8Array(bytes), mimeType);
await this.workspace.fs.writeFile(path, new Uint8Array(bytes));
}

async testChatWithError(errorMessage?: string): Promise<TestChatResult> {
Expand Down Expand Up @@ -8361,7 +8356,19 @@ export class ThinkMediaEvictionAgent extends Think {
}

async readWorkspaceFileForTest(path: string): Promise<string | null> {
return this.workspace.readFile(path);
try {
return await this.workspace.fs.readFile(path, "utf8");
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return null;
}
throw error;
}
}
}

Expand Down
Loading
Loading