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-legacy`. Think now consumes a narrow filesystem and runtime contract while the legacy workspace remains the default.
5 changes: 5 additions & 0 deletions .changeset/slow-spaces-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": minor
---

Add `@cloudflare/think/workspace`, an opt-in backend-free Computer workspace. Think's existing file tools and codemode `state.*` interface work with the provider without application adapters.
9 changes: 9 additions & 0 deletions packages/think/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/openai": "^4.0.0",
"@cloudflare/codemode": ">=0.5.0",
"@cloudflare/computer": "^0.2.0",
"@cloudflare/shell": ">=0.4.0",
"aywson": "^0.0.16",
"chat": "^4.31.0",
Expand Down Expand Up @@ -115,6 +116,14 @@
"types": "./dist/react.d.ts",
"import": "./dist/react.js"
},
"./workspace": {
"types": "./dist/workspace.d.ts",
"import": "./dist/workspace.js"
},
"./workspace-legacy": {
"types": "./dist/workspace-legacy.d.ts",
"import": "./dist/workspace-legacy.js"
},
"./tools/workspace": {
"types": "./dist/tools/workspace.d.ts",
"import": "./dist/tools/workspace.js"
Expand Down
2 changes: 2 additions & 0 deletions packages/think/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ async function main() {
"src/server-entry.ts",
"src/messengers/index.ts",
"src/messengers/telegram.ts",
"src/workspace.ts",
"src/workspace-legacy.ts",
"src/tools/workspace.ts",
"src/tools/fetch.ts",
"src/tools/execute.ts",
Expand Down
22 changes: 16 additions & 6 deletions packages/think/src/tests/agents/assistant-tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Agent } from "agents";
import { Workspace } from "@cloudflare/shell";
import type { ToolSet } from "ai";
import {
Workspace,
type BashToolOptions
} from "../../workspace/workspace-legacy";
import { createWorkspaceTools } from "../../tools/workspace";
import type { WorkspaceToolsOptions } from "../../tools/workspace";
import { workspaceToolProvider } from "../../workspace/types";

export class TestAssistantToolsAgent extends Agent {
workspace = new Workspace({
Expand All @@ -10,7 +14,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 +170,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
61 changes: 56 additions & 5 deletions packages/think/src/tests/agents/execute-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import { tool } from "ai";
import type { LanguageModel } from "ai";
import { z } from "zod";
import type { WorkspaceFsLike } from "@cloudflare/shell";
import { createWorkspaceStateBackend } from "@cloudflare/shell";
import type { DurableObjectStorageLike } from "@cloudflare/computer";
import { Think } from "../../think";
import { Workspace } from "../../workspace/workspace";
import {
createExecuteRuntime,
createExecuteTool,
Expand Down Expand Up @@ -56,9 +56,7 @@ export class ThinkExecuteToolAgent extends Think {
execute: async () => "boom"
})
},
state: createWorkspaceStateBackend(
this.workspace as unknown as WorkspaceFsLike
),
workspace: this.workspace,
loader: this.env.LOADER
});
}
Expand Down Expand Up @@ -90,3 +88,56 @@ export class ThinkExecuteToolAgent extends Think {
return (await this.codemode.executions()).map((e) => e.status);
}
}

export class ThinkComputerWorkspaceExecuteAgent extends Think {
override workspace = new Workspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike
});

#replay?: ExecuteRuntime;

getModel(): LanguageModel {
throw new Error("Model is not used in Computer workspace tests");
}

#replayRuntime(): ExecuteRuntime {
this.#replay ??= createExecuteRuntime(this, {
tools: {
checkpoint: tool({
description: "Pause execution for a replay test",
inputSchema: z.object({}),
needsApproval: true,
execute: async () => "approved"
})
}
});
return this.#replay;
}

async runWorkspaceExecute(code: string): Promise<ExecuteOutput> {
return invoke(createExecuteTool(this), code);
}

async runExplicitWorkspaceExecute(code: string): Promise<ExecuteOutput> {
return invoke(
createExecuteTool({
ctx: this.ctx,
workspace: this.workspace,
loader: this.env.LOADER
}),
code
);
}

async runWorkspaceReplay(code: string): Promise<ExecuteOutput> {
return invoke(this.#replayRuntime().tool, code);
}

async approveWorkspaceReplay(executionId: string): Promise<unknown> {
return this.#replayRuntime().runtime.approve({ executionId });
}

async writeWorkspaceFile(path: string, content: string): Promise<void> {
await this.workspace.fs.writeFile(path, content);
}
}
10 changes: 8 additions & 2 deletions packages/think/src/tests/agents/extension-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { workspaceFilesystem } from "../../workspace/types";
/**
* Test agent that loads a hooks-only extension subscribing to all four
* observation hooks (`beforeToolCall`, `afterToolCall`, `onStepFinish`,
Expand Down Expand Up @@ -182,7 +183,9 @@ export class ThinkExtensionHookAgent extends Think {

async listExtLogFiles(): Promise<string[]> {
try {
const entries = await this.workspace.readDir("ext-log");
const entries = await workspaceFilesystem(this.workspace).readdir(
"/ext-log"
);
return entries.map((e: { name: string }) => e.name);
} catch {
return [];
Expand All @@ -191,7 +194,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 workspaceFilesystem(this.workspace).readFile(
`/ext-log/${name}`,
"utf8"
);
if (content == null) return null;
return JSON.parse(content);
} catch {
Expand Down
5 changes: 4 additions & 1 deletion packages/think/src/tests/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export {
ThinkMediaEvictionAutoAgent
} from "./think-session";
export { ThinkFetchToolsTestAgent } from "./fetch-tools";
export { ThinkExecuteToolAgent } from "./execute-tool";
export {
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent
} from "./execute-tool";
export { ThinkExecuteHitlAgent } from "./execute-hitl";
export { ThinkFiberTestAgent } from "./fiber";
export { ThinkClientToolsAgent } from "./client-tools";
Expand Down
31 changes: 22 additions & 9 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { workspaceFilesystem } from "../../workspace/types";
import type { LanguageModel, ToolSet, UIMessage } from "ai";
import { hasToolCall, Output, tool } from "ai";
import { action, skills, Think } from "../../think";
Expand Down Expand Up @@ -1311,18 +1312,18 @@ 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 workspaceFilesystem(this.workspace).mkdir(parent, {
recursive: true
});
}
await writeFileBytes.call(workspace, path, new Uint8Array(bytes), mimeType);
await workspaceFilesystem(this.workspace).writeFile(
path,
new Uint8Array(bytes)
);
}

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

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

Expand Down
50 changes: 50 additions & 0 deletions packages/think/src/tests/execute-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@ async function freshAgent(name?: string) {
return getAgentByName(env.ThinkExecuteToolAgent, name ?? crypto.randomUUID());
}

describe("Computer workspace state connector", () => {
it("preserves state.* for the backend-free Computer provider", async () => {
const agent = await getAgentByName(
env.ThinkComputerWorkspaceExecuteAgent,
crypto.randomUUID()
);

const stateResult = await agent.runWorkspaceExecute(`async () => {
await state.writeFile({ path: "/notes.txt", content: "hello" });
await state.replaceInFile({
path: "/notes.txt",
search: "hello",
replacement: "updated"
});
const file = await state.readFile({ path: "/notes.txt" });
const listing = await state.glob({ pattern: "**/*.txt" });
return file === "updated" && listing.includes("/notes.txt");
}`);
expect(stateResult).toMatchObject({ status: "completed", result: true });

await expect(
agent.runExplicitWorkspaceExecute(`async () => {
return await state.readFile({ path: "/notes.txt" });
}`)
).resolves.toMatchObject({ status: "completed", result: "updated" });
});

it("re-executes state reads without repeating writes after approval", async () => {
const agent = await getAgentByName(
env.ThinkComputerWorkspaceExecuteAgent,
crypto.randomUUID()
);

const paused = await agent.runWorkspaceReplay(`async () => {
await state.writeFile({ path: "/replay.txt", content: "initial" });
const before = await state.readFile({ path: "/replay.txt" });
await tools.checkpoint({});
const after = await state.readFile({ path: "/replay.txt" });
return before === "external" && after === "external";
}`);
expect(paused).toMatchObject({ status: "paused" });
if (!paused.executionId) throw new Error("Missing paused execution id");

await agent.writeWorkspaceFile("/replay.txt", "external");
await expect(
agent.approveWorkspaceReplay(paused.executionId)
).resolves.toMatchObject({ status: "completed", result: true });
});
});

describe("execute tool on the codemode runtime", () => {
it("runs sandbox code against tools.* (ToolSetConnector)", async () => {
const agent = await freshAgent();
Expand Down
23 changes: 15 additions & 8 deletions packages/think/src/tests/fetch-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ function makeWorkspace() {
const bytes = new Map<string, Uint8Array>();
const dirs: string[] = [];
const ws: FetchWorkspace = {
mkdir: (p) => {
dirs.push(p);
},
writeFile: (p, c) => {
files.set(p, c);
},
writeFileBytes: (p, b) => {
bytes.set(p, b);
fs: {
mkdir: (p) => {
dirs.push(p);
return Promise.resolve();
},
writeFile: (p, content) => {
if (typeof content === "string") {
files.set(p, content);
} else if (content instanceof Uint8Array) {
bytes.set(p, content);
} else {
throw new Error("stream writes are not expected in this test");
}
return Promise.resolve();
}
}
};
return { files, bytes, dirs, ws };
Expand Down
3 changes: 3 additions & 0 deletions packages/think/src/tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
ThinkNestedMiddleAgent,
StuckThinkAgentToolChild,
ThinkExtensionHookAgent,
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent,
ThinkExecuteHitlAgent,
ThinkFetchToolsTestAgent,
Expand Down Expand Up @@ -73,6 +74,7 @@ import type {
ThinkNestedMiddleAgent,
StuckThinkAgentToolChild,
ThinkExtensionHookAgent,
ThinkComputerWorkspaceExecuteAgent,
ThinkExecuteToolAgent,
ThinkExecuteHitlAgent,
ThinkFetchToolsTestAgent,
Expand Down Expand Up @@ -218,6 +220,7 @@ export type Env = {
ThinkExtensionHookAgent: DurableObjectNamespace<ThinkExtensionHookAgent>;
ThinkMessengerRouteTestAgent: DurableObjectNamespace<ThinkMessengerRouteTestAgent>;
ThinkMcpToolMaterializationAgent: DurableObjectNamespace<ThinkMcpToolMaterializationAgent>;
ThinkComputerWorkspaceExecuteAgent: DurableObjectNamespace<ThinkComputerWorkspaceExecuteAgent>;
ThinkExecuteToolAgent: DurableObjectNamespace<ThinkExecuteToolAgent>;
ThinkExecuteHitlAgent: DurableObjectNamespace<ThinkExecuteHitlAgent>;
ThinkFetchToolsTestAgent: DurableObjectNamespace<ThinkFetchToolsTestAgent>;
Expand Down
Loading
Loading