diff --git a/.gitignore b/.gitignore index b4fa67d..32af540 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ temp/ .heft/ *.tsbuildinfo .env +.env.local +.env.*.local .fledgling/ diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 3cf7c4a..634b0fd 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -17,10 +17,10 @@ importers: version: 0.4.9 '@ai-sdk/mcp': specifier: 0.0.18 - version: 0.0.18(zod@3.25.76) + version: 0.0.18(zod@4.4.3) '@ai-sdk/openai': specifier: ^2.0.0 - version: 2.0.106(zod@3.25.76) + version: 2.0.106(zod@4.4.3) '@fledgling/agent-core': specifier: workspace:* version: link:../agent-core @@ -41,7 +41,7 @@ importers: version: link:../tools-mcp-node ai: specifier: ^5.0.0 - version: 5.0.193(zod@3.25.76) + version: 5.0.193(zod@4.4.3) devDependencies: '@fledgling/heft-rig': specifier: workspace:* @@ -109,7 +109,7 @@ importers: version: link:../context-builder ai: specifier: ^5.0.0 - version: 5.0.193(zod@3.25.76) + version: 5.0.193(zod@4.4.3) devDependencies: '@fledgling/heft-rig': specifier: workspace:* @@ -195,8 +195,39 @@ importers: specifier: ~5.9.0 version: 5.9.3 + ../../packages/mcp-workspace-browser: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.21.0 + version: 1.29.0(zod@3.25.76) + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + ../../packages/mcp-workspace-webcontainer: dependencies: + '@fledgling/mcp-workspace-browser': + specifier: workspace:* + version: link:../mcp-workspace-browser '@modelcontextprotocol/sdk': specifier: ^1.21.0 version: 1.29.0(zod@3.25.76) @@ -342,7 +373,7 @@ importers: version: 0.4.9 '@ai-sdk/mcp': specifier: 0.0.18 - version: 0.0.18(zod@3.25.76) + version: 0.0.18(zod@4.4.3) '@fledgling/agent-core': specifier: workspace:* version: link:../agent-core @@ -351,7 +382,7 @@ importers: version: link:../mcp-workspace ai: specifier: ^5.0.0 - version: 5.0.193(zod@3.25.76) + version: 5.0.193(zod@4.4.3) devDependencies: '@fledgling/heft-rig': specifier: workspace:* @@ -379,19 +410,19 @@ importers: version: 0.4.9 '@ai-sdk/mcp': specifier: 0.0.18 - version: 0.0.18(zod@3.25.76) + version: 0.0.18(zod@4.4.3) '@fledgling/agent-core': specifier: workspace:* version: link:../agent-core - '@fledgling/mcp-workspace-webcontainer': + '@fledgling/mcp-workspace-browser': specifier: workspace:* - version: link:../mcp-workspace-webcontainer + version: link:../mcp-workspace-browser '@fledgling/session-local-storage': specifier: workspace:* version: link:../session-local-storage ai: specifier: ^5.0.0 - version: 5.0.193(zod@3.25.76) + version: 5.0.193(zod@4.4.3) devDependencies: '@fledgling/heft-rig': specifier: workspace:* @@ -418,6 +449,92 @@ importers: specifier: ^6.0.1 version: 6.0.1(webpack@5.107.2) + ../../packages/web-demo: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.4.3 + version: 0.4.9 + '@ai-sdk/openai': + specifier: ^2.0.0 + version: 2.0.106(zod@4.4.3) + '@fledgling/mcp-workspace-webcontainer': + specifier: workspace:* + version: link:../mcp-workspace-webcontainer + '@fledgling/web-agent': + specifier: workspace:* + version: link:../web-agent + '@fledgling/workspace-nodepod': + specifier: workspace:* + version: link:../workspace-nodepod + '@mlc-ai/web-llm': + specifier: ^0.2.79 + version: 0.2.84 + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@scelar/nodepod': + specifier: 1.7.4 + version: 1.7.4(vite@7.3.5(@types/node@22.19.19)(terser@5.48.0)) + '@webcontainer/api': + specifier: ^1.5.1 + version: 1.6.4 + ai: + specifier: ^5.0.0 + version: 5.0.193(zod@4.4.3) + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + react: + specifier: ^19.2.3 + version: 19.2.7 + react-arborist: + specifier: ^3.4.3 + version: 3.8.0(@types/node@22.19.19)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: + specifier: ^19.2.3 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@22.19.19)(terser@5.48.0) + + ../../packages/workspace-nodepod: + dependencies: + '@fledgling/mcp-workspace-browser': + specifier: workspace:* + version: link:../mcp-workspace-browser + '@scelar/nodepod': + specifier: 1.7.4 + version: 1.7.4(vite@7.3.5(@types/node@22.19.19)(terser@5.48.0)) + devDependencies: + '@fledgling/heft-rig': + specifier: workspace:* + version: link:../../rigs/heft-rig + '@rushstack/heft': + specifier: 1.2.7 + version: 1.2.7(@types/node@22.19.19) + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(terser@5.48.0)) + typescript: + specifier: ~5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.6(@types/node@22.19.19)(terser@5.48.0) + ../../rigs/heft-rig: dependencies: '@rushstack/eslint-config': @@ -495,6 +612,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -769,6 +890,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@mlc-ai/web-llm@0.2.84': + resolution: {integrity: sha512-hrOWzK4/nGNmgoRKT8pgVmZZ2oEPpbblIWQOwpqNyvK2dysHw3KVB1gNJOuRcQfKOPhucEhX1NJzXzgMDnwSCQ==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -779,6 +903,16 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -802,6 +936,15 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@react-dnd/asap@4.0.1': + resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} + + '@react-dnd/invariant@2.0.0': + resolution: {integrity: sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==} + + '@react-dnd/shallowequal@2.0.0': + resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@rollup/rollup-android-arm-eabi@4.61.0': resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} cpu: [arm] @@ -1011,6 +1154,30 @@ packages: '@rushstack/ts-command-line@5.3.3': resolution: {integrity: sha512-c+ltdcvC7ym+10lhwR/vWiOhsrm/bP3By2VsFcs5qTKv+6tTmxgbVrtJ5NdNjANiV5TcmOZgUN+5KYQ4llsvEw==} + '@scelar/nodepod@1.7.4': + resolution: {integrity: sha512-ItC2jeSbQczmCt7vfFOFAWtvz5lxDnMdqArXLfSslYlxqudbawC7NwY1MXYaSI1WDieFcB5UThbgmOfQy9DTEg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@xterm/addon-fit': ^0.11.0 + '@xterm/addon-serialize': ^0.14.0 + '@xterm/addon-webgl': ^0.19.0 + '@xterm/xterm': ^6.0.0 + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@xterm/addon-fit': + optional: true + '@xterm/addon-serialize': + optional: true + '@xterm/addon-webgl': + optional: true + '@xterm/xterm': + optional: true + next: + optional: true + vite: + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1032,9 +1199,20 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/tapable@1.0.6': resolution: {integrity: sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1348,6 +1526,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.33: resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} engines: {node: '>=6.0.0'} @@ -1371,6 +1552,13 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli-wasm@3.0.1: + resolution: {integrity: sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==} + engines: {node: '>=v18.0.0'} + + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1436,6 +1624,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comlink@4.4.2: + resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -1474,6 +1665,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1517,10 +1711,16 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dnd-core@14.0.1: + resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -1901,6 +2101,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} @@ -2195,6 +2398,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2219,6 +2426,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2227,6 +2439,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -2268,6 +2483,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2364,6 +2582,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2477,9 +2698,49 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-arborist@3.8.0: + resolution: {integrity: sha512-66UQK2mWtodjkHg4efiIYjQt0VlFhQ4LXTphcqHi0+1Jc7hAxcxAC1SbmCUCpZYUW7C+14WgsnYgBRqG0AYb1A==} + peerDependencies: + react: '>= 16.14' + react-dom: '>= 16.14' + + react-dnd-html5-backend@14.1.0: + resolution: {integrity: sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==} + + react-dnd@14.0.5: + resolution: {integrity: sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==} + peerDependencies: + '@types/hoist-non-react-statics': '>= 3.3.1' + '@types/node': '>= 12' + '@types/react': '>= 16' + react: '>= 16.14' + peerDependenciesMeta: + '@types/hoist-non-react-statics': + optional: true + '@types/node': + optional: true + '@types/react': + optional: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-window@1.8.11: + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -2491,6 +2752,12 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2515,6 +2782,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -2560,6 +2831,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@4.3.3: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} @@ -2663,6 +2937,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2889,6 +3166,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3065,38 +3347,41 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@agentclientprotocol/sdk@0.4.9': dependencies: zod: 3.25.76 - '@ai-sdk/gateway@2.0.94(zod@3.25.76)': + '@ai-sdk/gateway@2.0.94(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.25(zod@4.4.3) '@vercel/oidc': 3.1.0 - zod: 3.25.76 + zod: 4.4.3 - '@ai-sdk/mcp@0.0.18(zod@3.25.76)': + '@ai-sdk/mcp@0.0.18(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.25(zod@4.4.3) pkce-challenge: 5.0.1 - zod: 3.25.76 + zod: 4.4.3 - '@ai-sdk/openai@2.0.106(zod@3.25.76)': + '@ai-sdk/openai@2.0.106(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(zod@3.25.76) - zod: 3.25.76 + '@ai-sdk/provider-utils': 3.0.25(zod@4.4.3) + zod: 4.4.3 - '@ai-sdk/provider-utils@3.0.25(zod@3.25.76)': + '@ai-sdk/provider-utils@3.0.25(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.0 - zod: 3.25.76 + zod: 4.4.3 '@ai-sdk/provider@2.0.3': dependencies: @@ -3115,6 +3400,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -3315,6 +3602,10 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@mlc-ai/web-llm@0.2.84': + dependencies: + loglevel: 1.9.2 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.23) @@ -3337,6 +3628,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3356,6 +3658,12 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@react-dnd/asap@4.0.1': {} + + '@react-dnd/invariant@2.0.0': {} + + '@react-dnd/shallowequal@2.0.0': {} + '@rollup/rollup-android-arm-eabi@4.61.0': optional: true @@ -3573,6 +3881,19 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@scelar/nodepod@1.7.4(vite@7.3.5(@types/node@22.19.19)(terser@5.48.0))': + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + brotli: 1.3.3 + brotli-wasm: 3.0.1 + comlink: 4.4.2 + pako: 2.1.0 + resolve.exports: 2.0.3 + zod: 4.4.3 + optionalDependencies: + vite: 7.3.5(@types/node@22.19.19)(terser@5.48.0) + '@standard-schema/spec@1.1.0': {} '@types/argparse@1.0.38': {} @@ -3592,8 +3913,19 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/tapable@1.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3862,13 +4194,13 @@ snapshots: acorn@8.16.0: {} - ai@5.0.193(zod@3.25.76): + ai@5.0.193(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 2.0.94(zod@3.25.76) + '@ai-sdk/gateway': 2.0.94(zod@4.4.3) '@ai-sdk/provider': 2.0.3 - '@ai-sdk/provider-utils': 3.0.25(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.25(zod@4.4.3) '@opentelemetry/api': 1.9.0 - zod: 3.25.76 + zod: 4.4.3 ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: @@ -4001,6 +4333,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.33: {} body-parser@2.2.2: @@ -4034,6 +4368,12 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli-wasm@3.0.1: {} + + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.33 @@ -4100,6 +4440,8 @@ snapshots: colorette@2.0.20: {} + comlink@4.4.2: {} + commander@12.1.0: {} commander@2.20.3: {} @@ -4127,6 +4469,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -4169,10 +4513,20 @@ snapshots: depd@2.0.0: {} + dnd-core@14.0.1: + dependencies: + '@react-dnd/asap': 4.0.1 + '@react-dnd/invariant': 2.0.0 + redux: 4.2.1 + doctrine@2.1.0: dependencies: esutils: 2.0.3 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -4695,6 +5049,10 @@ snapshots: help-me@5.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + hono@4.12.23: {} html-escaper@2.0.2: {} @@ -4981,6 +5339,8 @@ snapshots: lodash.merge@4.6.2: {} + loglevel@1.9.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5007,10 +5367,14 @@ snapshots: dependencies: semver: 7.5.4 + marked@14.0.0: {} + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} + memoize-one@5.2.1: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -5044,6 +5408,11 @@ snapshots: minipass@7.1.3: {} + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} nanoid@3.3.12: {} @@ -5144,6 +5513,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5262,8 +5633,52 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-arborist@3.8.0(@types/node@22.19.19)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dnd: 14.0.5(@types/node@22.19.19)(@types/react@19.2.17)(react@19.2.7) + react-dnd-html5-backend: 14.1.0 + react-dom: 19.2.7(react@19.2.7) + react-window: 1.8.11(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + redux: 5.0.1 + use-sync-external-store: 1.6.0(react@19.2.7) + transitivePeerDependencies: + - '@types/hoist-non-react-statics' + - '@types/node' + - '@types/react' + + react-dnd-html5-backend@14.1.0: + dependencies: + dnd-core: 14.0.1 + + react-dnd@14.0.5(@types/node@22.19.19)(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@react-dnd/invariant': 2.0.0 + '@react-dnd/shallowequal': 2.0.0 + dnd-core: 14.0.1 + fast-deep-equal: 3.1.3 + hoist-non-react-statics: 3.3.2 + react: 19.2.7 + optionalDependencies: + '@types/node': 22.19.19 + '@types/react': 19.2.17 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + react-is@16.13.1: {} + react-window@1.8.11(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + memoize-one: 5.2.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + real-require@0.2.0: {} real-require@1.0.0: {} @@ -5272,6 +5687,12 @@ snapshots: dependencies: resolve: 1.22.12 + redux@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5302,6 +5723,8 @@ snapshots: resolve-from@5.0.0: {} + resolve.exports@2.0.3: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -5388,6 +5811,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 @@ -5515,6 +5940,8 @@ snapshots: stackback@0.0.2: {} + state-local@1.0.7: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -5730,6 +6157,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + vary@1.1.2: {} vite-node@3.2.4(@types/node@22.19.19)(terser@5.48.0): @@ -5960,3 +6391,5 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/packages/acp-agent/src/agent.ts b/packages/acp-agent/src/agent.ts index e59b739..9e23ba9 100644 --- a/packages/acp-agent/src/agent.ts +++ b/packages/acp-agent/src/agent.ts @@ -1,14 +1,14 @@ import { createOpenAI, type OpenAIProvider } from "@ai-sdk/openai"; import { + AiSdkModelTurnRunner, + type AiSdkToolChoice, type FledglingAgentDependencies, type IModelTurnRunner, - type ModelTurnRequest, - type ModelTurnResult, - type ModelStreamPart + type ModelTurnResult } from "@fledgling/agent-core"; import { FileSystemSessionManager } from "@fledgling/session-file-system"; import { NodeMcpToolProvider } from "@fledgling/tools-mcp-node"; -import { stepCountIs, streamText, type LanguageModel, type ToolSet } from "ai"; +import { type LanguageModel, type ToolSet } from "ai"; export { FledglingAgent, @@ -26,31 +26,29 @@ const DEFAULT_SYSTEM_PROMPT: string = "You are Fledgling, a small ACP-native assistant. Answer directly. Use tools when they are available and useful. If the user asks you to inspect, create, modify, delete, search, or execute something in the workspace, use the relevant workspace tool instead of only describing what you would do. If the user asks you to write content to a file, call the file-writing tool. Do not claim you cannot access files when a relevant workspace tool is available. Tool results may include Fledgling context hints that describe identity, retention, and prompt placement for future context assembly."; export class VercelAiSdkModelTurnRunner implements IModelTurnRunner { - public runModelTurn(request: ModelTurnRequest): ModelTurnResult { - const openai = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY, - baseURL: process.env.OPENAI_BASE_URL - }); - const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; - const model = selectOpenAiModel(openai, modelName); + readonly #runner: AiSdkModelTurnRunner; - const result = streamText({ - model, - system: process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, - messages: request.messages, - tools: request.tools, - toolChoice: getToolChoice(request.tools), - stopWhen: stepCountIs(5), - abortSignal: request.abortSignal + public constructor() { + this.#runner = new AiSdkModelTurnRunner({ + resolveModel: () => { + const openai = createOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL + }); + const modelName = process.env.OPENAI_MODEL ?? "gpt-4.1-mini"; + return selectOpenAiModel(openai, modelName); + }, + resolveSystemPrompt: () => process.env.FLEDGLING_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT, + resolveToolChoice: getToolChoice }); + } - return { - fullStream: result.fullStream as AsyncIterable - }; + public runModelTurn(...args: Parameters): ModelTurnResult { + return this.#runner.runModelTurn(...args); } } -function getToolChoice(tools: ToolSet): "auto" | { type: "tool"; toolName: string } { +function getToolChoice(tools: ToolSet): AiSdkToolChoice { const toolName = process.env.FLEDGLING_TOOL_CHOICE; if (!toolName) { return "auto"; diff --git a/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts b/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts new file mode 100644 index 0000000..79d531b --- /dev/null +++ b/packages/agent-core/src/ai-sdk-model-turn-runner.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AiSdkModelTurnRunner } from "./ai-sdk-model-turn-runner.js"; + +const mocks = vi.hoisted(() => { + const fullStream = (async function* (): AsyncIterable {})(); + const stopWhen = { kind: "stop-when" }; + return { + fullStream, + stopWhen, + streamText: vi.fn(() => ({ fullStream })), + stepCountIs: vi.fn(() => stopWhen) + }; +}); + +vi.mock("ai", () => ({ + streamText: mocks.streamText, + stepCountIs: mocks.stepCountIs +})); + +describe("AiSdkModelTurnRunner", () => { + beforeEach(() => { + mocks.streamText.mockClear(); + mocks.stepCountIs.mockClear(); + }); + + it("passes messages, tools, abort signal, and defaults into streamText", () => { + const model = { modelId: "test-model" }; + const messages = [{ role: "user" as const, content: "hello" }]; + const tools = {}; + const abortController = new AbortController(); + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => model as never, + resolveSystemPrompt: () => "system prompt" + }); + + const result = runner.runModelTurn({ + messages, + tools, + abortSignal: abortController.signal + }); + + expect(result.fullStream).toBe(mocks.fullStream); + expect(mocks.stepCountIs).toHaveBeenCalledWith(5); + expect(mocks.streamText).toHaveBeenCalledWith({ + model, + system: "system prompt", + messages, + tools, + toolChoice: "auto", + stopWhen: mocks.stopWhen, + abortSignal: abortController.signal + }); + }); + + it("honors an injected tool choice resolver", () => { + const toolChoice = { type: "tool" as const, toolName: "workspace.read_file" }; + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => ({}) as never, + resolveToolChoice: () => toolChoice + }); + + runner.runModelTurn({ + messages: [], + tools: {}, + abortSignal: new AbortController().signal + }); + + expect(mocks.streamText).toHaveBeenCalledWith(expect.objectContaining({ toolChoice })); + }); + + it("uses the configured max step count", () => { + const runner = new AiSdkModelTurnRunner({ + resolveModel: () => ({}) as never, + maxSteps: 3 + }); + + runner.runModelTurn({ + messages: [], + tools: {}, + abortSignal: new AbortController().signal + }); + + expect(mocks.stepCountIs).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/agent-core/src/ai-sdk-model-turn-runner.ts b/packages/agent-core/src/ai-sdk-model-turn-runner.ts new file mode 100644 index 0000000..11d7e0d --- /dev/null +++ b/packages/agent-core/src/ai-sdk-model-turn-runner.ts @@ -0,0 +1,36 @@ +import { stepCountIs, streamText, type LanguageModel, type ToolSet } from "ai"; + +import type { IModelTurnRunner, ModelStreamPart, ModelTurnRequest, ModelTurnResult } from "./interfaces.js"; + +export type AiSdkToolChoice = "auto" | { readonly type: "tool"; readonly toolName: string }; + +export interface AiSdkModelTurnRunnerOptions { + readonly resolveModel: () => LanguageModel; + readonly resolveSystemPrompt?: () => string | undefined; + readonly resolveToolChoice?: (tools: ToolSet) => AiSdkToolChoice | undefined; + readonly maxSteps?: number; +} + +export class AiSdkModelTurnRunner implements IModelTurnRunner { + readonly #options: AiSdkModelTurnRunnerOptions; + + public constructor(options: AiSdkModelTurnRunnerOptions) { + this.#options = options; + } + + public runModelTurn(request: ModelTurnRequest): ModelTurnResult { + const result = streamText({ + model: this.#options.resolveModel(), + system: this.#options.resolveSystemPrompt?.(), + messages: request.messages, + tools: request.tools, + toolChoice: this.#options.resolveToolChoice?.(request.tools) ?? "auto", + stopWhen: stepCountIs(this.#options.maxSteps ?? 5), + abortSignal: request.abortSignal + }); + + return { + fullStream: result.fullStream as AsyncIterable + }; + } +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index bf4dd88..0ff8418 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./ai-sdk-model-turn-runner.js"; export * from "./agent.js"; export * from "./interfaces.js"; export * from "./prompt-content.js"; diff --git a/packages/mcp-workspace-browser/config/rig.json b/packages/mcp-workspace-browser/config/rig.json new file mode 100644 index 0000000..64b84c1 --- /dev/null +++ b/packages/mcp-workspace-browser/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@fledgling/heft-rig" +} diff --git a/packages/mcp-workspace-browser/eslint.config.cjs b/packages/mcp-workspace-browser/eslint.config.cjs new file mode 100644 index 0000000..1c0a01e --- /dev/null +++ b/packages/mcp-workspace-browser/eslint.config.cjs @@ -0,0 +1,3 @@ +const rigConfig = require("@fledgling/heft-rig/profiles/default/config/eslint.config.cjs"); + +module.exports = [...rigConfig]; diff --git a/packages/mcp-workspace-browser/package.json b/packages/mcp-workspace-browser/package.json new file mode 100644 index 0000000..dd1029e --- /dev/null +++ b/packages/mcp-workspace-browser/package.json @@ -0,0 +1,28 @@ +{ + "name": "@fledgling/mcp-workspace-browser", + "version": "0.0.0", + "private": true, + "description": "Browser MCP workspace sidecar backed by pluggable browser runtimes.", + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "test": "vitest run", + "_phase:test": "vitest run --coverage" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@fledgling/heft-rig": "workspace:*", + "@rushstack/heft": "1.2.7", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.1.1", + "vitest": "^3.1.1", + "typescript": "~5.9.0" + } +} diff --git a/packages/mcp-workspace-browser/src/index.ts b/packages/mcp-workspace-browser/src/index.ts new file mode 100644 index 0000000..a70d94f --- /dev/null +++ b/packages/mcp-workspace-browser/src/index.ts @@ -0,0 +1,2 @@ +export * from "./runtime.js"; +export * from "./sidecar.js"; diff --git a/packages/mcp-workspace-browser/src/runtime.test.ts b/packages/mcp-workspace-browser/src/runtime.test.ts new file mode 100644 index 0000000..a747b8e --- /dev/null +++ b/packages/mcp-workspace-browser/src/runtime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { + appendSearchMatches, + joinAbsoluteWorkspacePath, + joinWorkspacePath, + normalizeAbsoluteWorkspacePath, + normalizeWorkspacePath +} from "./runtime.js"; + +describe("browser workspace path helpers", () => { + it("normalizes relative and absolute workspace paths", () => { + expect(normalizeWorkspacePath("/src\\index.ts")).toBe("src/index.ts"); + expect(normalizeWorkspacePath("/")).toBe("."); + expect(normalizeAbsoluteWorkspacePath("/src\\index.ts")).toBe("/src/index.ts"); + expect(normalizeAbsoluteWorkspacePath(".")).toBe("/"); + }); + + it("joins relative and absolute workspace paths", () => { + expect(joinWorkspacePath(".", "README.md")).toBe("README.md"); + expect(joinWorkspacePath("src", "index.ts")).toBe("src/index.ts"); + expect(joinAbsoluteWorkspacePath("/", "README.md")).toBe("/README.md"); + expect(joinAbsoluteWorkspacePath("/src", "index.ts")).toBe("/src/index.ts"); + }); + + it("appends line-oriented search matches", () => { + const matches: { path: string; line: number; text: string }[] = []; + appendSearchMatches(matches, "README.md", "hello\nneedle here", "needle"); + expect(matches).toEqual([{ path: "README.md", line: 2, text: "needle here" }]); + }); +}); diff --git a/packages/mcp-workspace-browser/src/runtime.ts b/packages/mcp-workspace-browser/src/runtime.ts new file mode 100644 index 0000000..d5c1752 --- /dev/null +++ b/packages/mcp-workspace-browser/src/runtime.ts @@ -0,0 +1,64 @@ +export interface WorkspaceFileEntry { + readonly type: "file"; + readonly name: string; + readonly path: string; + readonly sizeBytes: number; +} + +export interface WorkspaceDirectoryEntry { + readonly type: "directory"; + readonly name: string; + readonly path: string; +} + +export type WorkspaceEntry = WorkspaceFileEntry | WorkspaceDirectoryEntry; + +export interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly timedOut: boolean; + readonly truncated: boolean; +} + +export interface IWorkspaceRuntime { + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + listDirectory(path: string): Promise; + searchText(query: string, path: string): Promise; + runCommand(command: string, cwd: string, timeoutMs: number, maxOutputBytes: number): Promise; + dispose?(): Promise | void; +} + +export interface SearchMatch { + readonly path: string; + readonly line: number; + readonly text: string; +} + +export function appendSearchMatches(matches: SearchMatch[], path: string, content: string, query: string): void { + const lines = content.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + if (line.includes(query)) { + matches.push({ path, line: index + 1, text: line }); + } + } +} + +export function normalizeWorkspacePath(path: string): string { + const normalized = path.replaceAll("\\", "/").replace(/^\/+/, ""); + return normalized.length === 0 || normalized === "." ? "." : normalized; +} + +export function normalizeAbsoluteWorkspacePath(path: string): string { + const normalized = normalizeWorkspacePath(path); + return normalized === "." ? "/" : `/${normalized}`; +} + +export function joinWorkspacePath(parent: string, child: string): string { + return parent === "." ? child : `${parent}/${child}`; +} + +export function joinAbsoluteWorkspacePath(parent: string, child: string): string { + return parent === "/" ? `/${child}` : `${parent}/${child}`; +} diff --git a/packages/mcp-workspace-webcontainer/src/sidecar.ts b/packages/mcp-workspace-browser/src/sidecar.ts similarity index 93% rename from packages/mcp-workspace-webcontainer/src/sidecar.ts rename to packages/mcp-workspace-browser/src/sidecar.ts index 31bd197..a687cb2 100644 --- a/packages/mcp-workspace-webcontainer/src/sidecar.ts +++ b/packages/mcp-workspace-browser/src/sidecar.ts @@ -21,15 +21,15 @@ export interface WebWorkspaceSidecar { close(): Promise; } -export async function createWebContainerWorkspaceSidecar(runtime: IWorkspaceRuntime): Promise { +export async function createWebWorkspaceSidecar(runtime: IWorkspaceRuntime): Promise { const server = new McpServer( { - name: "fledgling-webcontainer-workspace", + name: "fledgling-browser-workspace", version: "0.0.0" }, { instructions: - "Browser workspace tools run through a WebContainer-backed MCP sidecar. Use read_file before edits; command output is latest evidence." + "Browser workspace tools run through a browser-hosted MCP sidecar. Use read_file before edits; command output is latest evidence." } ); registerWebWorkspaceTools(server, runtime); @@ -165,7 +165,7 @@ function toolResult( function fileContextHint(path: string, content: string): ContextHint { return { kind: "durable_resource", - identity: `webcontainer://${path}`, + identity: `browser-workspace://${path}`, contentHash: hashText(content), tokenEstimate: estimateTokens(content), placement: "session_context", @@ -178,7 +178,7 @@ function fileContextHint(path: string, content: string): ContextHint { function commandContextHint(command: string, output: string): ContextHint { return { kind: "command_output", - identity: `webcontainer-command://${hashText(command)}`, + identity: `browser-command://${hashText(command)}`, contentHash: hashText(output), tokenEstimate: estimateTokens(output), placement: "latest_evidence", diff --git a/packages/mcp-workspace-browser/tsconfig.json b/packages/mcp-workspace-browser/tsconfig.json new file mode 100644 index 0000000..83592d4 --- /dev/null +++ b/packages/mcp-workspace-browser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../rigs/heft-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/mcp-workspace-browser/vitest.config.ts b/packages/mcp-workspace-browser/vitest.config.ts new file mode 100644 index 0000000..99f4bce --- /dev/null +++ b/packages/mcp-workspace-browser/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node" + } +}); diff --git a/packages/mcp-workspace-webcontainer/package.json b/packages/mcp-workspace-webcontainer/package.json index 300c625..300c96b 100644 --- a/packages/mcp-workspace-webcontainer/package.json +++ b/packages/mcp-workspace-webcontainer/package.json @@ -14,6 +14,7 @@ "_phase:test": "vitest run --coverage" }, "dependencies": { + "@fledgling/mcp-workspace-browser": "workspace:*", "@modelcontextprotocol/sdk": "^1.21.0", "@webcontainer/api": "^1.5.1", "zod": "^3.25.0" diff --git a/packages/mcp-workspace-webcontainer/src/index.ts b/packages/mcp-workspace-webcontainer/src/index.ts index a70d94f..c9435f8 100644 --- a/packages/mcp-workspace-webcontainer/src/index.ts +++ b/packages/mcp-workspace-webcontainer/src/index.ts @@ -1,2 +1,2 @@ export * from "./runtime.js"; -export * from "./sidecar.js"; +export * from "@fledgling/mcp-workspace-browser"; diff --git a/packages/mcp-workspace-webcontainer/src/runtime.ts b/packages/mcp-workspace-webcontainer/src/runtime.ts index 7d67d7f..d2acc9d 100644 --- a/packages/mcp-workspace-webcontainer/src/runtime.ts +++ b/packages/mcp-workspace-webcontainer/src/runtime.ts @@ -1,41 +1,13 @@ import type { WebContainer } from "@webcontainer/api"; - -export interface WorkspaceFileEntry { - readonly type: "file"; - readonly name: string; - readonly path: string; - readonly sizeBytes: number; -} - -export interface WorkspaceDirectoryEntry { - readonly type: "directory"; - readonly name: string; - readonly path: string; -} - -export type WorkspaceEntry = WorkspaceFileEntry | WorkspaceDirectoryEntry; - -export interface CommandResult { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; - readonly timedOut: boolean; - readonly truncated: boolean; -} - -export interface IWorkspaceRuntime { - readFile(path: string): Promise; - writeFile(path: string, content: string): Promise; - listDirectory(path: string): Promise; - searchText(query: string, path: string): Promise; - runCommand(command: string, cwd: string, timeoutMs: number, maxOutputBytes: number): Promise; -} - -export interface SearchMatch { - readonly path: string; - readonly line: number; - readonly text: string; -} +import { + appendSearchMatches, + joinWorkspacePath, + normalizeWorkspacePath, + type CommandResult, + type IWorkspaceRuntime, + type SearchMatch, + type WorkspaceEntry +} from "@fledgling/mcp-workspace-browser"; export class WebContainerWorkspaceRuntime implements IWorkspaceRuntime { readonly #container: WebContainer; @@ -196,21 +168,3 @@ function killProcess(process: unknown): void { const candidate = process as { kill?: () => void }; candidate.kill?.(); } - -function appendSearchMatches(matches: SearchMatch[], path: string, content: string, query: string): void { - const lines = content.split(/\r?\n/); - for (const [index, line] of lines.entries()) { - if (line.includes(query)) { - matches.push({ path, line: index + 1, text: line }); - } - } -} - -function normalizeWorkspacePath(path: string): string { - const normalized = path.replaceAll("\\", "/").replace(/^\/+/, ""); - return normalized.length === 0 || normalized === "." ? "." : normalized; -} - -function joinWorkspacePath(parent: string, child: string): string { - return parent === "." ? child : `${parent}/${child}`; -} diff --git a/packages/web-agent/package.json b/packages/web-agent/package.json index aa20f79..e7dee9b 100644 --- a/packages/web-agent/package.json +++ b/packages/web-agent/package.json @@ -19,7 +19,7 @@ "@agentclientprotocol/sdk": "^0.4.3", "@ai-sdk/mcp": "0.0.18", "@fledgling/agent-core": "workspace:*", - "@fledgling/mcp-workspace-webcontainer": "workspace:*", + "@fledgling/mcp-workspace-browser": "workspace:*", "@fledgling/session-local-storage": "workspace:*", "ai": "^5.0.0" }, diff --git a/packages/web-agent/src/dependencies.ts b/packages/web-agent/src/dependencies.ts index 74cc0ee..722d9af 100644 --- a/packages/web-agent/src/dependencies.ts +++ b/packages/web-agent/src/dependencies.ts @@ -5,9 +5,9 @@ import { FledglingAgent } from "@fledgling/agent-core"; import { - createWebContainerWorkspaceSidecar, + createWebWorkspaceSidecar, type IWorkspaceRuntime -} from "@fledgling/mcp-workspace-webcontainer"; +} from "@fledgling/mcp-workspace-browser"; import { LocalStorageSessionManager } from "@fledgling/session-local-storage"; import { WebMcpToolProvider } from "./tool-provider.js"; @@ -22,7 +22,7 @@ export function createWebAgentDependencies(options: WebAgentOptions): FledglingA return { sessionManager: new LocalStorageSessionManager({ storage: options.storage }), toolProvider: new WebMcpToolProvider({ - createSidecar: () => createWebContainerWorkspaceSidecar(options.workspaceRuntime) + createSidecar: () => createWebWorkspaceSidecar(options.workspaceRuntime) }), modelTurnRunner: options.modelTurnRunner, logger: console diff --git a/packages/web-agent/src/index.ts b/packages/web-agent/src/index.ts index a746f10..6966fe8 100644 --- a/packages/web-agent/src/index.ts +++ b/packages/web-agent/src/index.ts @@ -1,4 +1,4 @@ export * from "@fledgling/agent-core"; -export * from "@fledgling/mcp-workspace-webcontainer"; +export * from "@fledgling/mcp-workspace-browser"; export * from "./dependencies.js"; export * from "./tool-provider.js"; diff --git a/packages/web-agent/src/tool-provider.ts b/packages/web-agent/src/tool-provider.ts index 9ff546f..4508115 100644 --- a/packages/web-agent/src/tool-provider.ts +++ b/packages/web-agent/src/tool-provider.ts @@ -5,7 +5,7 @@ import { } from "@ai-sdk/mcp"; import type { IToolProvider, SessionTools } from "@fledgling/agent-core"; import type * as acp from "@agentclientprotocol/sdk"; -import type { WebWorkspaceSidecar } from "@fledgling/mcp-workspace-webcontainer"; +import type { WebWorkspaceSidecar } from "@fledgling/mcp-workspace-browser"; import type { ToolSet } from "ai"; export interface WebMcpToolProviderOptions { diff --git a/packages/web-agent/src/web-agent.test.ts b/packages/web-agent/src/web-agent.test.ts index 32617ed..1631d21 100644 --- a/packages/web-agent/src/web-agent.test.ts +++ b/packages/web-agent/src/web-agent.test.ts @@ -1,7 +1,7 @@ import type { IModelTurnRunner, ModelTurnRequest } from "@fledgling/agent-core"; import { describe, expect, it, vi } from "vitest"; -import type { IWorkspaceRuntime } from "@fledgling/mcp-workspace-webcontainer"; +import type { IWorkspaceRuntime } from "@fledgling/mcp-workspace-browser"; import { createWebAgent } from "./index.js"; diff --git a/packages/web-demo/.env.example b/packages/web-demo/.env.example new file mode 100644 index 0000000..28a060b --- /dev/null +++ b/packages/web-demo/.env.example @@ -0,0 +1,4 @@ +VITE_OPENAI_BASE_URL=https://api.openai.com/v1 +VITE_OPENAI_API_KEY= +VITE_OPENAI_MODEL=gpt-4.1-mini +VITE_WEBLLM_MODEL=Qwen3.5-0.8B-q4f16_1-MLC diff --git a/packages/web-demo/index.html b/packages/web-demo/index.html new file mode 100644 index 0000000..f2d1de4 --- /dev/null +++ b/packages/web-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + Fledgling Web Demo + + +
+ + + diff --git a/packages/web-demo/package.json b/packages/web-demo/package.json new file mode 100644 index 0000000..994be6b --- /dev/null +++ b/packages/web-demo/package.json @@ -0,0 +1,36 @@ +{ + "name": "@fledgling/web-demo", + "version": "0.0.0", + "private": true, + "description": "Minimal browser demo for the Fledgling web agent.", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit && vite build", + "_phase:build": "tsc -p tsconfig.json --noEmit && vite build", + "dev": "vite --host 127.0.0.1", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.4.3", + "@ai-sdk/openai": "^2.0.0", + "@fledgling/mcp-workspace-webcontainer": "workspace:*", + "@fledgling/web-agent": "workspace:*", + "@fledgling/workspace-nodepod": "workspace:*", + "@monaco-editor/react": "^4.7.0", + "@mlc-ai/web-llm": "^0.2.79", + "@webcontainer/api": "^1.5.1", + "ai": "^5.0.0", + "monaco-editor": "^0.55.1", + "react-arborist": "^3.4.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@scelar/nodepod": "1.7.4" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "typescript": "~5.9.0", + "vite": "^7.3.5" + } +} diff --git a/packages/web-demo/src/App.tsx b/packages/web-demo/src/App.tsx new file mode 100644 index 0000000..11455ee --- /dev/null +++ b/packages/web-demo/src/App.tsx @@ -0,0 +1,446 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type { IWorkspaceRuntime, WorkspaceEntry } from "@fledgling/web-agent"; +import { type FormEvent, type ReactElement, useEffect, useMemo, useRef, useState } from "react"; + +import { + createDemoSession, + createDemoWorkspace, + type DemoSession, + type DemoWorkspaceProvider +} from "./acp-demo.js"; +import { DemoView, type DemoMessage, type WorkspaceBrowserModel } from "./components.js"; +import { + createModelTurnRunner, + type BrowserModelTurnRunner, + type DemoModelProvider, + type ModelLoadStatus +} from "./model-runner.js"; + +export function App(): ReactElement { + const webGpuAvailable = useMemo(() => "gpu" in navigator, []); + const [provider, setProvider] = useState("openai"); + const [workspaceProvider, setWorkspaceProvider] = useState("nodepod"); + const [modelStatus, setModelStatus] = useState({ + provider: "openai", + ready: true, + text: "Remote endpoint", + device: "CPU" + }); + const modelRunner = useMemo( + () => + createModelTurnRunner({ + provider, + envSource: import.meta.env, + onStatusChange: setModelStatus + }), + [provider] + ); + const modelRunnerRef = useRef(modelRunner); + const workspaceRuntimeRef = useRef(undefined); + const selectedPathRef = useRef(undefined); + const [workspaceRuntime, setWorkspaceRuntime] = useState(); + const [session, setSession] = useState(); + const [messages, setMessages] = useState([]); + const [assistantDraft, setAssistantDraft] = useState(""); + const [prompt, setPrompt] = useState(""); + const [status, setStatus] = useState("Starting"); + const [pending, setPending] = useState(false); + const [browser, setBrowser] = useState({ + entries: [], + selectedPath: undefined, + preview: "", + error: undefined + }); + + useEffect(() => { + selectedPathRef.current = browser.selectedPath; + }, [browser.selectedPath]); + + useEffect(() => { + modelRunnerRef.current = modelRunner; + setModelStatus(modelRunner.status); + if (workspaceRuntimeRef.current) { + void startSession(workspaceRuntimeRef.current); + } + + if (modelRunner.warmup) { + void modelRunner.warmup().catch((error) => { + setMessages((current) => [...current, { role: "error", text: errorText(error) }]); + }); + } + + return () => { + void modelRunner.dispose?.(); + }; + }, [modelRunner]); + + useEffect(() => { + let disposed = false; + async function boot(): Promise { + try { + setStatus("Starting workspace"); + const runtime = await createDemoWorkspace(workspaceProvider); + if (disposed) { + await runtime.dispose?.(); + return; + } + + setWorkspaceRuntime(runtime); + workspaceRuntimeRef.current = runtime; + await refreshFileBrowser(runtime); + await startSession(runtime); + } catch (error) { + setMessages((current) => [...current, { role: "error", text: errorText(error) }]); + setStatus("Startup error"); + } + } + + void boot(); + return () => { + disposed = true; + }; + }, [workspaceProvider]); + + async function startSession(runtime: IWorkspaceRuntime): Promise { + setStatus("Starting"); + setSession(await createDemoSession(modelRunnerRef.current, runtime, handleSessionUpdate)); + setStatus("Ready"); + } + + function handleSessionUpdate(params: acp.SessionNotification): void { + const { update } = params; + + if (update.sessionUpdate === "agent_message_chunk") { + const text = contentBlockText(update.content); + if (text !== undefined) { + setAssistantDraft((current) => current + text); + } + return; + } + + if (update.sessionUpdate === "tool_call") { + setMessages((current) => [ + ...current, + { + role: "tool", + toolCallId: update.toolCallId, + toolName: update.title, + status: update.status ?? "pending", + args: formatJson(update.rawInput) + } + ]); + return; + } + + if (update.sessionUpdate === "tool_call_update") { + setMessages((current) => updateToolMessage(current, update)); + if (update.status === "completed" && workspaceRuntimeRef.current) { + void refreshFileBrowser(workspaceRuntimeRef.current, selectedPathRef.current); + } + } + } + + async function sendPrompt(event: FormEvent): Promise { + event.preventDefault(); + const text = prompt.trim(); + if (!text || pending || !session || !modelRunner.ready) { + return; + } + + setMessages((current) => [...current, { role: "user", text }]); + setAssistantDraft(""); + setPrompt(""); + setPending(true); + setStatus("Running"); + + try { + const response = await session.connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text }] + }); + setAssistantDraft((draft) => { + if (draft) { + setMessages((current) => [...current, { role: "assistant", text: draft }]); + } + + return ""; + }); + setStatus(response.stopReason); + } catch (error) { + setMessages((current) => [...current, { role: "error", text: errorText(error) }]); + setStatus("Error"); + } finally { + setPending(false); + } + } + + async function cancelPrompt(): Promise { + if (session && pending) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + } + + async function newSession(): Promise { + if (!workspaceRuntime) { + return; + } + + if (session) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + + setMessages([]); + setAssistantDraft(""); + await startSession(workspaceRuntime); + } + + async function changeProvider(nextProvider: DemoModelProvider): Promise { + if (nextProvider === provider) { + return; + } + + if (session) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + + setMessages([]); + setAssistantDraft(""); + setPending(false); + setProvider(nextProvider); + } + + async function changeWorkspaceProvider(nextProvider: DemoWorkspaceProvider): Promise { + if (nextProvider === workspaceProvider) { + return; + } + + if (session) { + await session.connection.cancel({ sessionId: session.sessionId }); + } + + await workspaceRuntimeRef.current?.dispose?.(); + workspaceRuntimeRef.current = undefined; + selectedPathRef.current = undefined; + setWorkspaceRuntime(undefined); + setSession(undefined); + setMessages([]); + setAssistantDraft(""); + setPending(false); + setBrowser({ + entries: [], + selectedPath: undefined, + preview: "", + error: undefined + }); + setStatus("Starting workspace"); + setWorkspaceProvider(nextProvider); + } + + async function refreshFileBrowser( + runtime = workspaceRuntimeRef.current ?? workspaceRuntime, + selectedPath = selectedPathRef.current + ): Promise { + if (!runtime) { + return; + } + + try { + const entries = await collectWorkspaceEntries(runtime, "."); + let preview = ""; + let nextSelectedPath = selectedPath; + if (nextSelectedPath) { + preview = await runtime.readFile(nextSelectedPath); + } else { + const firstFile = entries.find((entry) => entry.type === "file"); + nextSelectedPath = firstFile?.path; + preview = firstFile ? await runtime.readFile(firstFile.path) : ""; + } + + setBrowser({ + entries, + selectedPath: nextSelectedPath, + preview, + error: undefined + }); + selectedPathRef.current = nextSelectedPath; + } catch (error) { + setBrowser((current) => ({ ...current, error: errorText(error) })); + } + } + + async function openWorkspaceFile(path: string): Promise { + if (!workspaceRuntime) { + return; + } + + try { + setBrowser((current) => ({ + ...current, + selectedPath: path, + preview: "", + error: undefined + })); + const preview = await workspaceRuntime.readFile(path); + setBrowser((current) => ({ + ...current, + selectedPath: path, + preview, + error: undefined + })); + selectedPathRef.current = path; + } catch (error) { + setBrowser((current) => ({ + ...current, + selectedPath: path, + error: errorText(error) + })); + } + } + + return ( + void cancelPrompt()} + onModelProviderChange={(nextProvider) => void changeProvider(nextProvider)} + onWorkspaceProviderChange={(nextProvider) => void changeWorkspaceProvider(nextProvider)} + onOpenFile={(path) => void openWorkspaceFile(path)} + onRefreshFiles={() => void refreshFileBrowser()} + onNewSession={() => void newSession()} + onPromptChange={setPrompt} + onSubmit={(event) => void sendPrompt(event)} + /> + ); +} + +async function collectWorkspaceEntries( + runtime: IWorkspaceRuntime, + path: string +): Promise { + const entries = await runtime.listDirectory(path); + const result: WorkspaceEntry[] = []; + + for (const entry of entries.sort((left, right) => compareEntries(left, right))) { + result.push(entry); + if (entry.type === "directory") { + if (shouldSkipDirectory(entry.name)) { + continue; + } + + result.push(...(await collectWorkspaceEntries(runtime, entry.path))); + } + } + + return result; +} + +function shouldSkipDirectory(name: string): boolean { + return name === "node_modules" || name === ".git" || name === ".cache" || name === "dist" || name === "lib"; +} + +function compareEntries( + left: WorkspaceBrowserModel["entries"][number], + right: WorkspaceBrowserModel["entries"][number] +): number { + if (left.type !== right.type) { + return left.type === "directory" ? -1 : 1; + } + + return left.name.localeCompare(right.name); +} + +function updateToolMessage( + messages: readonly DemoMessage[], + update: ToolCallUpdate +): DemoMessage[] { + const next = [...messages]; + const index = next.findIndex((message) => message.role === "tool" && message.toolCallId === update.toolCallId); + const text = extractToolUpdateText(update); + if (index === -1) { + next.push({ + role: "tool", + toolCallId: update.toolCallId, + toolName: update.toolCallId, + status: update.status ?? "pending", + result: text, + error: update.status === "failed" ? text : undefined + }); + return next; + } + + const current = next[index]; + if (current.role !== "tool") { + return next; + } + + next[index] = { + ...current, + status: update.status ?? "pending", + result: update.status === "failed" ? current.result : text, + error: update.status === "failed" ? text : current.error + }; + return next; +} + +function extractToolUpdateText(update: ToolCallUpdate): string | undefined { + const parts = update.content ?? []; + const text = parts + .map((part) => (part.type === "content" && part.content.type === "text" ? part.content.text : undefined)) + .filter((part): part is string => part !== undefined) + .join("\n"); + return text.length > 0 ? text : undefined; +} + +function formatJson(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + const text = safeStringify(value); + return text === undefined ? undefined : text; +} + +function errorText(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return safeStringify(error) ?? String(error); +} + +function safeStringify(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + + try { + return JSON.stringify(value, undefined, 2); + } catch { + return undefined; + } +} + +function contentBlockText(content: unknown): string | undefined { + if (typeof content !== "object" || content === null) { + return undefined; + } + + const maybeText = content as { readonly type?: unknown; readonly text?: unknown }; + return maybeText.type === "text" && typeof maybeText.text === "string" ? maybeText.text : undefined; +} + +type ToolCallUpdate = Extract; diff --git a/packages/web-demo/src/acp-demo.ts b/packages/web-demo/src/acp-demo.ts new file mode 100644 index 0000000..19d8be0 --- /dev/null +++ b/packages/web-demo/src/acp-demo.ts @@ -0,0 +1,151 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type { IModelTurnRunner, IWorkspaceRuntime } from "@fledgling/web-agent"; + +export type DemoWorkspaceProvider = "nodepod" | "webcontainer"; + +export interface DemoSession { + readonly connection: acp.ClientSideConnection; + readonly protocolVersion: string; + readonly sessionId: string; +} + +class DemoClient { + readonly #onUpdate: (params: acp.SessionNotification) => void; + + public constructor(onUpdate: (params: acp.SessionNotification) => void) { + this.#onUpdate = onUpdate; + } + + public async sessionUpdate(params: acp.SessionNotification): Promise { + this.#onUpdate(params); + } + + public async requestPermission(): Promise { + return { outcome: { outcome: "cancelled" } }; + } + + public async writeTextFile(): Promise { + return {}; + } + + public async readTextFile(): Promise { + return { content: "" }; + } +} + +export async function createDemoSession( + modelTurnRunner: IModelTurnRunner, + workspaceRuntime: IWorkspaceRuntime, + onUpdate: (params: acp.SessionNotification) => void +): Promise { + const { createWebAgent } = await import("@fledgling/web-agent"); + const streams = createConnectionStreams(); + const client = new DemoClient(onUpdate); + + new acp.AgentSideConnection( + (connection) => + createWebAgent(connection, { + modelTurnRunner, + workspaceRuntime + }), + streams.agent + ); + + const connection = new acp.ClientSideConnection(() => client, streams.client); + const init = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {} + }); + const session = await connection.newSession({ + cwd: "/browser", + mcpServers: [] + }); + + return { + connection, + protocolVersion: String(init.protocolVersion), + sessionId: session.sessionId + }; +} + +export async function createDemoWorkspace(provider: DemoWorkspaceProvider): Promise { + if (provider === "webcontainer") { + return createWebContainerDemoWorkspace(); + } + + return createNodepodDemoWorkspace(); +} + +async function createNodepodDemoWorkspace(): Promise { + const { createNodepodWorkspaceRuntime } = await import("@fledgling/workspace-nodepod"); + return createNodepodWorkspaceRuntime({ + files: { + "/README.md": + "# Fledgling browser workspace\n\nThis workspace is backed by NodePod. Try asking the agent to list files, write notes.txt, run pwd, or write and execute a Node script.\n", + "/package.json": JSON.stringify( + { + name: "fledgling-browser-workspace", + private: true, + type: "module", + scripts: { + hello: "node index.js" + } + }, + undefined, + 2 + ), + "/index.js": "console.log('hello from NodePod');\n" + }, + workdir: "/" + }); +} + +async function createWebContainerDemoWorkspace(): Promise { + const [{ WebContainer }, { WebContainerWorkspaceRuntime }] = await Promise.all([ + import("@webcontainer/api"), + import("@fledgling/mcp-workspace-webcontainer") + ]); + const container = await WebContainer.boot(); + await container.mount({ + "README.md": { + file: { + contents: + "# Fledgling browser workspace\n\nThis workspace is backed by WebContainer. Try asking the agent to list files, write notes.txt, or run pwd.\n" + } + }, + "package.json": { + file: { + contents: JSON.stringify( + { + name: "fledgling-browser-workspace", + private: true, + type: "module", + scripts: { + hello: "echo hello from WebContainer" + } + }, + undefined, + 2 + ) + } + } + }); + + return new WebContainerWorkspaceRuntime(container); +} + +function createConnectionStreams(): { readonly client: acp.Stream; readonly agent: acp.Stream } { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + + return { + client: { + writable: clientToAgent.writable, + readable: agentToClient.readable + } as acp.Stream, + agent: { + writable: agentToClient.writable, + readable: clientToAgent.readable + } as acp.Stream + }; +} diff --git a/packages/web-demo/src/components.tsx b/packages/web-demo/src/components.tsx new file mode 100644 index 0000000..76149bb --- /dev/null +++ b/packages/web-demo/src/components.tsx @@ -0,0 +1,448 @@ +import type { WorkspaceEntry } from "@fledgling/web-agent"; +import Editor from "@monaco-editor/react"; +import type { FormEvent, ReactElement } from "react"; +import { Tree, type NodeApi, type NodeRendererProps } from "react-arborist"; + +import type { DemoWorkspaceProvider } from "./acp-demo.js"; +import type { DemoModelProvider, ModelLoadStatus } from "./model-runner.js"; + +export type DemoMessage = + | TextDemoMessage + | { + readonly role: "tool"; + readonly toolCallId: string; + readonly toolName: string; + readonly status: string; + readonly args?: string; + readonly result?: string; + readonly error?: string; + }; + +export interface TextDemoMessage { + readonly role: "user" | "assistant" | "error"; + readonly text: string; +} + +export interface RuntimeDetails { + readonly endpoint: string; + readonly model: string; + readonly provider: DemoModelProvider; + readonly workspaceProvider: DemoWorkspaceProvider; + readonly modelStatus: ModelLoadStatus; + readonly webGpuAvailable: boolean; + readonly protocolVersion: string | undefined; + readonly sessionId: string | undefined; +} + +export interface WorkspaceBrowserModel { + readonly entries: readonly WorkspaceEntry[]; + readonly selectedPath: string | undefined; + readonly preview: string; + readonly error: string | undefined; +} + +export interface DemoViewProps { + readonly assistantDraft: string; + readonly browser: WorkspaceBrowserModel; + readonly messages: readonly DemoMessage[]; + readonly pending: boolean; + readonly prompt: string; + readonly runtime: RuntimeDetails; + readonly status: string; + readonly onCancel: () => void; + readonly onModelProviderChange: (provider: DemoModelProvider) => void; + readonly onWorkspaceProviderChange: (provider: DemoWorkspaceProvider) => void; + readonly onOpenFile: (path: string) => void; + readonly onRefreshFiles: () => void; + readonly onNewSession: () => void; + readonly onPromptChange: (prompt: string) => void; + readonly onSubmit: (event: FormEvent) => void; +} + +export function DemoView(props: DemoViewProps): ReactElement { + return ( +
+
+
+ + +
+ +
+ ); +} + +export function Header({ + model, + provider, + status, + workspaceProvider, + webGpuAvailable, + onModelProviderChange, + onWorkspaceProviderChange +}: { + readonly model: string; + readonly provider: DemoModelProvider; + readonly status: string; + readonly workspaceProvider: DemoWorkspaceProvider; + readonly webGpuAvailable: boolean; + readonly onModelProviderChange: (provider: DemoModelProvider) => void; + readonly onWorkspaceProviderChange: (provider: DemoWorkspaceProvider) => void; +}): ReactElement { + return ( +
+
+

Fledgling

+

{status}

+
+
+ + +
{model}
+
+
+ ); +} + +export function Transcript({ + assistantDraft, + messages +}: { + readonly assistantDraft: string; + readonly messages: readonly DemoMessage[]; +}): ReactElement { + return ( +
+ {messages.map((message, index) => ( + + ))} + {assistantDraft ? : undefined} +
+ ); +} + +export function MessageView({ message }: { readonly message: DemoMessage }): ReactElement { + if (message.role === "tool") { + return ; + } + + return ( +
+
{message.role}
+
{message.text}
+
+ ); +} + +export function ToolMessageView({ + message +}: { + readonly message: Extract; +}): ReactElement { + return ( +
+
+ tool ยท {message.status} + {message.toolName} +
+
+ {message.args ? ( + <> +
args
+
{message.args}
+ + ) : undefined} + {message.result ? ( + <> +
result
+
{message.result}
+ + ) : undefined} + {message.error ? ( + <> +
error
+
{message.error}
+ + ) : undefined} +
+
+ ); +} + +export function Composer({ + pending, + prompt, + ready, + onCancel, + onPromptChange, + onSubmit +}: { + readonly pending: boolean; + readonly prompt: string; + readonly ready: boolean; + readonly onCancel: () => void; + readonly onPromptChange: (prompt: string) => void; + readonly onSubmit: (event: FormEvent) => void; +}): ReactElement { + return ( +
+