diff --git a/package.json b/package.json index ad897b367..18e3bc2d1 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "hydrate-component-library": "tsx scripts/hydrate-component-library.ts public/component_library.original.yaml public/component_library.yaml", "agent:server": "tsx scripts/agent/server.ts", "agent:index": "tsx scripts/agent/registry/indexer.ts", - "agent:index-docs": "tsx scripts/agent/registry/docsIndexer.ts" + "agent:index-docs": "tsx scripts/agent/registry/docsIndexer.ts", + "agent:publish-index": "mkdir -p public/agent-index && cp scripts/agent/registry/.vector-store.json public/agent-index/vector-store.json && cp scripts/agent/registry/.docs-vector-store.json public/agent-index/docs-vector-store.json", + "agent:publish-skills": "mkdir -p public/agent-skills && rsync -a --delete scripts/agent/skills/ public/agent-skills/" }, "dependencies": { "@bugsnag/js": "^8.9.0", @@ -91,6 +93,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "comlink": "^4.4.2", "date-fns": "^4.2.1", "dexie": "^4.4.2", "dexie-react-hooks": "^4.4.0", @@ -105,6 +108,9 @@ "mobx-keystone": "^1.21.0", "mobx-react-lite": "^4.1.1", "nanoid": "^5.1.11", + "openai": "^6.33.0", + "@openai/agents": "^0.4.0", + "@openai/agents-core": "^0.4.0", "papaparse": "^5.5.3", "prism-react-renderer": "^2.4.1", "pyodide": "^0.29.4", @@ -131,7 +137,6 @@ "@babel/plugin-proposal-decorators": "^7.29.0", "@eslint/js": "^9.39.2", "@hey-api/openapi-ts": "^0.97.2", - "@langchain/anthropic": "^1.3.26", "@langchain/classic": "^1.0.30", "@langchain/core": "^1.1.39", "@langchain/langgraph": "^1.2.8", @@ -166,7 +171,6 @@ "jsdom": "^29.1.1", "knip": "^6.14.1", "langchain": "^1.3.1", - "openai": "^6.33.0", "prettier": "^3.8.3", "tsx": "^4.22.3", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5dedce93..22848e317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@openai/agents': + specifier: ^0.4.0 + version: 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) + '@openai/agents-core': + specifier: ^0.4.0 + version: 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -119,6 +125,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + comlink: + specifier: ^4.4.2 + version: 4.4.2 date-fns: specifier: ^4.2.1 version: 4.2.1 @@ -164,6 +173,9 @@ importers: nanoid: specifier: ^5.1.11 version: 5.1.11 + openai: + specifier: ^6.33.0 + version: 6.34.0(ws@8.20.1)(zod@4.4.3) papaparse: specifier: ^5.5.3 version: 5.5.3 @@ -228,9 +240,6 @@ importers: '@hey-api/openapi-ts': specifier: ^0.97.2 version: 0.97.2(magicast@0.3.5)(typescript@5.9.3) - '@langchain/anthropic': - specifier: ^1.3.26 - version: 1.3.26(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) '@langchain/classic': specifier: ^1.0.30 version: 1.0.30(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) @@ -239,7 +248,7 @@ importers: version: 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) '@langchain/langgraph': specifier: ^1.2.8 - version: 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + version: 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) '@langchain/openai': specifier: ^1.4.4 version: 1.4.4(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(ws@8.20.1) @@ -290,7 +299,7 @@ importers: version: 1.0.0 deepagents: specifier: ^1.9.0 - version: 1.9.0(langsmith@0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1) + version: 1.9.0(langsmith@0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)(zod-to-json-schema@3.25.2(zod@4.4.3)) dependency-cruiser: specifier: ^17.4.0 version: 17.4.0 @@ -332,10 +341,7 @@ importers: version: 6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) langchain: specifier: ^1.3.1 - version: 1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1) - openai: - specifier: ^6.33.0 - version: 6.34.0(ws@8.20.1)(zod@4.4.3) + version: 1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)(zod-to-json-schema@3.25.2(zod@4.4.3)) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -367,15 +373,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/sdk@0.74.0': - resolution: {integrity: sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1046,6 +1043,12 @@ packages: '@hey-api/types@0.1.4': resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1110,12 +1113,6 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@langchain/anthropic@1.3.26': - resolution: {integrity: sha512-8gfnM1MzZkb3HVD0WjWeb/HFdP4cNGWSokhBtrwW0qSJN+b1j9oBMwWZaVdd+VBKsx4hqzv0bdrMzWje0TMw+g==} - engines: {node: '>=20'} - peerDependencies: - '@langchain/core': ^1.1.38 - '@langchain/classic@1.0.30': resolution: {integrity: sha512-EKqx8NtbvY5pKj4lQHyzm9pLN+rElOiGobObjdEARvFgzamrmxx/nAK1cGOv3N3LvaCtf+dkGRdHaeXqXSPM2w==} engines: {node: '>=20'} @@ -1189,6 +1186,16 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@monaco-editor/loader@1.7.0': resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} @@ -1217,6 +1224,29 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@openai/agents-core@0.4.15': + resolution: {integrity: sha512-LDCg7GnKLgAj2Zt5f3Wi40gedez9Oh1zoLA8UgoBGKs2cLwShIMLYoeh8DTE6fHKupzdnRHScAroT3EVTV8JOw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@openai/agents-openai@0.4.15': + resolution: {integrity: sha512-JCcHyi3IAv3VdUUmnUyQI5EYiAMf9DRsuH3rgxkROdkaHQ2OBVpvwqpRFNMPOKHuLIyZuSPuP/Lf1HcfeXU2zA==} + peerDependencies: + zod: ^4.0.0 + + '@openai/agents-realtime@0.4.15': + resolution: {integrity: sha512-i3eUVCjXFu9AhuhMDZynl+NBaGfpyGRgZ9CYPEvKRlZhxSYnQabtpdDyat7Dv+iljPR1+fzXJcMgci7mQLQFFg==} + peerDependencies: + zod: ^4.0.0 + + '@openai/agents@0.4.15': + resolution: {integrity: sha512-O3CJf8BIA2FoYtUy4cHmFqNcPNi2mjFIcKOySfV8m5BTkZJXElzs6zRWI6TNcEpp4nuJNB/xnKrJz3hSwQI+pw==} + peerDependencies: + zod: ^4.0.0 + '@oxc-parser/binding-android-arm-eabi@0.130.0': resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2551,6 +2581,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.59.4': resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2691,9 +2724,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3007,6 +3051,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comlink@4.4.2: + resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3071,6 +3118,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3467,10 +3518,24 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -3508,6 +3573,9 @@ packages: fast-safe-stringify@1.2.3: resolution: {integrity: sha512-QJYT/i0QYoiZBQ71ivxdyTqkwKkQ0oxACXHYxH2zYHJEgzi2LsbjgvtzTbLi1SZcF190Db2YP7I7eTsU2egOlw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3773,6 +3841,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.12.21: + resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3845,6 +3917,10 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4067,6 +4143,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -4107,13 +4186,15 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-ts@3.1.1: - resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} - engines: {node: '>=16'} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4817,6 +4898,10 @@ packages: resolution: {integrity: sha512-LFDwmhyWLBnmwO/2UFbWu1jEGVDzaPupaVdx0XcZ3tIAx1EDEBauzxXf2S0UcFK7oe+X9MApjH0hx9U1XMgfCA==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -5505,9 +5590,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-algebra@2.0.0: - resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -5955,6 +6037,11 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod-validation-error@3.5.4: resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} engines: {node: '>=18.0.0'} @@ -5994,12 +6081,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/sdk@0.74.0(zod@4.4.3)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.4.3 - '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -6597,6 +6678,11 @@ snapshots: '@hey-api/types@0.1.4': {} + '@hono/node-server@1.19.14(hono@4.12.21)': + dependencies: + hono: 4.12.21 + optional: true + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6670,12 +6756,6 @@ snapshots: '@jsdevtools/ono@7.1.3': {} - '@langchain/anthropic@1.3.26(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))': - dependencies: - '@anthropic-ai/sdk': 0.74.0(zod@4.4.3) - '@langchain/core': 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) - zod: 4.4.3 - '@langchain/classic@1.0.30(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)': dependencies: '@langchain/core': 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) @@ -6733,7 +6813,7 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@langchain/langgraph@1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3)': + '@langchain/langgraph@1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)': dependencies: '@langchain/core': 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) @@ -6741,6 +6821,8 @@ snapshots: '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 4.4.3 + optionalDependencies: + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - react - react-dom @@ -6763,6 +6845,31 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.21) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.21 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + optional: true + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -6793,6 +6900,57 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@openai/agents-core@0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3)': + dependencies: + debug: 4.4.3 + openai: 6.34.0(ws@8.20.1)(zod@4.4.3) + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + + '@openai/agents-openai@0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@openai/agents-core': 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) + debug: 4.4.3 + openai: 6.34.0(ws@8.20.1)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + + '@openai/agents-realtime@0.4.15(@cfworker/json-schema@4.1.1)(zod@4.4.3)': + dependencies: + '@openai/agents-core': 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) + '@types/ws': 8.18.1 + debug: 4.4.3 + ws: 8.20.1 + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + + '@openai/agents@0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@openai/agents-core': 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) + '@openai/agents-openai': 0.4.15(@cfworker/json-schema@4.1.1)(ws@8.20.1)(zod@4.4.3) + '@openai/agents-realtime': 0.4.15(@cfworker/json-schema@4.1.1)(zod@4.4.3) + debug: 4.4.3 + openai: 6.34.0(ws@8.20.1)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + - ws + '@oxc-parser/binding-android-arm-eabi@0.130.0': optional: true @@ -7931,6 +8089,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.39 + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -8141,6 +8303,11 @@ snapshots: acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + optional: true + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8148,6 +8315,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + optional: true + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -8481,6 +8656,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comlink@4.4.2: {} + comma-separated-tokens@2.0.3: {} command-line-args@5.2.1: @@ -8537,6 +8714,12 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8638,13 +8821,13 @@ snapshots: deep-is@0.1.4: {} - deepagents@1.9.0(langsmith@0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1): + deepagents@1.9.0(langsmith@0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)(zod-to-json-schema@3.25.2(zod@4.4.3)): dependencies: '@langchain/core': 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) - '@langchain/langgraph': 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + '@langchain/langgraph': 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) '@langchain/langgraph-sdk': 1.8.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) fast-glob: 3.3.3 - langchain: 1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1) + langchain: 1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)(zod-to-json-schema@3.25.2(zod@4.4.3)) langsmith: 0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) micromatch: 4.0.8 yaml: 2.9.0 @@ -9085,8 +9268,22 @@ snapshots: eventemitter3@5.0.4: {} + eventsource-parser@3.0.8: + optional: true + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + optional: true + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + optional: true + express@5.2.1: dependencies: accepts: 2.0.0 @@ -9148,6 +9345,9 @@ snapshots: fast-safe-stringify@1.2.3: {} + fast-uri@3.1.2: + optional: true + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9448,6 +9648,9 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.12.21: + optional: true + hosted-git-info@2.8.9: {} html-encoding-sniffer@6.0.0: @@ -9512,6 +9715,9 @@ snapshots: interpret@3.1.1: {} + ip-address@10.2.0: + optional: true + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -9720,6 +9926,9 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: + optional: true + js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -9771,13 +9980,14 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-ts@3.1.1: - dependencies: - '@babel/runtime': 7.29.2 - ts-algebra: 2.0.0 - json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: + optional: true + + json-schema-typed@8.0.2: + optional: true + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-deterministic@1.0.13: {} @@ -9831,10 +10041,10 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - langchain@1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1): + langchain@1.3.2(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)(zod-to-json-schema@3.25.2(zod@4.4.3)): dependencies: '@langchain/core': 1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) - '@langchain/langgraph': 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + '@langchain/langgraph': 1.2.8(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.39(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1)) langsmith: 0.5.18(openai@6.34.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) uuid: 11.1.0 @@ -10710,6 +10920,9 @@ snapshots: quick-format-unescaped: 1.1.2 split2: 2.2.0 + pkce-challenge@5.0.1: + optional: true + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -11522,8 +11735,6 @@ snapshots: trough@2.2.0: {} - ts-algebra@2.0.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -11965,6 +12176,11 @@ snapshots: yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + optional: true + zod-validation-error@3.5.4(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/scripts/agent/ARCHITECTURE.md b/scripts/agent/ARCHITECTURE.md index 34769a845..15c911b7b 100644 --- a/scripts/agent/ARCHITECTURE.md +++ b/scripts/agent/ARCHITECTURE.md @@ -697,9 +697,11 @@ Agents emit standard Markdown links whose `href` uses a custom protocol: ```markdown + [Preprocess Data](entity://task_abc123) + [Train XGBoost](component://train-xgboost-on-csv) ``` @@ -709,12 +711,12 @@ The link label becomes the chip's display text. The identifier after the protoco Renders an **EntityChip** (`EntityChip.tsx`) for tasks, inputs, and outputs in the current pipeline. -| Aspect | Detail | -| --------------- | ---------------------------------------------------------------------------------------- | -| **Protocol** | `entity://` | -| **Resolved by** | Looks up the entity in the current `rootSpec` (tasks, inputs, outputs) | +| Aspect | Detail | +| --------------- | ------------------------------------------------------------------------------------------------ | +| **Protocol** | `entity://` | +| **Resolved by** | Looks up the entity in the current `rootSpec` (tasks, inputs, outputs) | | **Icon** | Context-aware: `SquareFunction` (task), `ArrowRightToLine` (input), `ArrowLeftFromLine` (output) | -| **Click** | Navigates the editor to the referenced entity (selects + focuses the node on the canvas) | +| **Click** | Navigates the editor to the referenced entity (selects + focuses the node on the canvas) | ### `component://` — Component Registry Chip diff --git a/src/agent/agents/subagents/debugAssistant.ts b/src/agent/agents/subagents/debugAssistant.ts new file mode 100644 index 000000000..dfb249ebf --- /dev/null +++ b/src/agent/agents/subagents/debugAssistant.ts @@ -0,0 +1,40 @@ +import { Agent } from "@openai/agents"; + +import { config } from "../../config"; +import { attachObservabilityHooks } from "../../middleware/observability"; +import debugAssistantPrompt from "../../prompts/debugAssistant.md?raw"; +import type { AgentSession, RecentPipelineRun } from "../../session"; +import { createCsomTools } from "../../tools/csomTools"; +import { executionDebugTools } from "../../tools/debugTools"; + +function formatRecentRunsContext(runs: RecentPipelineRun[]): string { + if (runs.length === 0) return ""; + + const lines = runs.map( + (r) => + `- Run ${r.id} | status: ${r.status ?? "unknown"} | root_exec: ${r.root_execution_id} | by: ${r.created_by} | ${r.created_at}`, + ); + return ( + "\n\n## Recent Pipeline Runs (from the frontend)\n\n" + + lines.join("\n") + + "\n\nUse these run IDs and root_execution_ids directly — no need to ask the user for them." + ); +} + +export function createDebugAssistantAgent(session: AgentSession): Agent { + const csom = createCsomTools(session.bridge); + const instructions = + debugAssistantPrompt + formatRecentRunsContext(session.recentRuns); + + const agent = new Agent({ + name: "debug-assistant", + handoffDescription: + "Help users understand why pipeline runs failed. Fetches run data, analyzes " + + "per-task statuses and error logs, and explains the root cause. Read-only — does not modify the pipeline.", + instructions, + tools: [csom.getPipelineState, ...executionDebugTools], + model: config.subagentModel, + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} diff --git a/src/agent/agents/subagents/generalHelp.ts b/src/agent/agents/subagents/generalHelp.ts new file mode 100644 index 000000000..5a3e6f967 --- /dev/null +++ b/src/agent/agents/subagents/generalHelp.ts @@ -0,0 +1,22 @@ +import { Agent } from "@openai/agents"; + +import { config } from "../../config"; +import { attachObservabilityHooks } from "../../middleware/observability"; +import generalHelpPrompt from "../../prompts/generalHelp.md?raw"; +import type { AgentSession } from "../../session"; +import { createSearchComponentsTool } from "../../tools/searchComponents"; +import { searchDocsTool } from "../../tools/searchDocs"; + +export function createGeneralHelpAgent(session: AgentSession): Agent { + const agent = new Agent({ + name: "general-help", + handoffDescription: + "Answer general questions about Tangle concepts, features, best practices, " + + "and product behavior. Not specific to the current pipeline.", + instructions: generalHelpPrompt, + tools: [createSearchComponentsTool(session), searchDocsTool], + model: config.subagentModel, + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} diff --git a/src/agent/agents/subagents/genericAssistant.ts b/src/agent/agents/subagents/genericAssistant.ts new file mode 100644 index 000000000..6616936e0 --- /dev/null +++ b/src/agent/agents/subagents/genericAssistant.ts @@ -0,0 +1,23 @@ +import { Agent } from "@openai/agents"; + +import { config } from "../../config"; +import { attachObservabilityHooks } from "../../middleware/observability"; +import genericAssistantPrompt from "../../prompts/genericAssistant.md?raw"; +import type { AgentSession } from "../../session"; +import { createCsomTools } from "../../tools/csomTools"; +import { createSearchComponentsTool } from "../../tools/searchComponents"; + +export function createGenericAssistantAgent(session: AgentSession): Agent { + const csom = createCsomTools(session.bridge); + const agent = new Agent({ + name: "generic-assistant", + handoffDescription: + "Explain what a pipeline does, describe data flow, clarify component behavior, " + + "and answer questions about the current pipeline state. Read-only — never modifies the pipeline.", + instructions: genericAssistantPrompt, + tools: [csom.getPipelineState, createSearchComponentsTool(session)], + model: config.subagentModel, + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} diff --git a/src/agent/agents/subagents/pipelineArchitect.ts b/src/agent/agents/subagents/pipelineArchitect.ts new file mode 100644 index 000000000..6d1b8ec0b --- /dev/null +++ b/src/agent/agents/subagents/pipelineArchitect.ts @@ -0,0 +1,29 @@ +import { Agent } from "@openai/agents"; + +import { config } from "../../config"; +import { attachObservabilityHooks } from "../../middleware/observability"; +import architectPrompt from "../../prompts/architect.md?raw"; +import type { AgentSession } from "../../session"; +import { createCsomTools } from "../../tools/csomTools"; +import { pipelineRunTools } from "../../tools/runTools"; +import { createSearchComponentsTool } from "../../tools/searchComponents"; + +export function createPipelineArchitectAgent(session: AgentSession): Agent { + const csom = createCsomTools(session.bridge); + const agent = new Agent({ + name: "pipeline-architect", + handoffDescription: + "Build new pipelines or add stages to existing ones. Assembles registry " + + "components into a graph using CSOM tools. Use for constructive tasks like " + + '"build a CSV dedup pipeline" or "add an output stage". Cannot create custom Python components.', + instructions: architectPrompt, + tools: [ + ...csom.allTools, + createSearchComponentsTool(session), + ...pipelineRunTools, + ], + model: config.orchestratorModel, + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} diff --git a/src/agent/agents/subagents/pipelineRepair.ts b/src/agent/agents/subagents/pipelineRepair.ts new file mode 100644 index 000000000..5eba39f43 --- /dev/null +++ b/src/agent/agents/subagents/pipelineRepair.ts @@ -0,0 +1,24 @@ +import { Agent } from "@openai/agents"; + +import { config } from "../../config"; +import { attachObservabilityHooks } from "../../middleware/observability"; +import pipelineRepairPrompt from "../../prompts/pipelineRepair.md?raw"; +import type { AgentSession } from "../../session"; +import { createCsomTools } from "../../tools/csomTools"; +import { createSearchComponentsTool } from "../../tools/searchComponents"; + +export function createPipelineRepairAgent(session: AgentSession): Agent { + const csom = createCsomTools(session.bridge); + const agent = new Agent({ + name: "pipeline-repair", + handoffDescription: + "Diagnose and fix validation issues, broken connections, missing inputs, and other " + + "structural problems in existing pipelines. Can mutate the pipeline via CSOM tools. " + + "Asks the user for input when fixes are ambiguous.", + instructions: pipelineRepairPrompt, + tools: [...csom.allTools, createSearchComponentsTool(session)], + model: config.orchestratorModel, + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} diff --git a/src/agent/agents/tangleDispatcher.ts b/src/agent/agents/tangleDispatcher.ts new file mode 100644 index 000000000..0f2c8250e --- /dev/null +++ b/src/agent/agents/tangleDispatcher.ts @@ -0,0 +1,79 @@ +import { Agent, MemorySession, run } from "@openai/agents"; +import { RECOMMENDED_PROMPT_PREFIX } from "@openai/agents-core/extensions"; + +import { config, ensureProxyConfigured } from "../config"; +import { attachObservabilityHooks } from "../middleware/observability"; +import dispatcherPrompt from "../prompts/dispatcher.md?raw"; +import type { AgentSession } from "../session"; +import { createCsomTools } from "../tools/csomTools"; +import { createDebugAssistantAgent } from "./subagents/debugAssistant"; +import { createGeneralHelpAgent } from "./subagents/generalHelp"; +import { createGenericAssistantAgent } from "./subagents/genericAssistant"; +import { createPipelineArchitectAgent } from "./subagents/pipelineArchitect"; +import { createPipelineRepairAgent } from "./subagents/pipelineRepair"; + +// Per-thread session memory lives for the lifetime of the worker. Persisting +// across reloads would require a custom Session backed by Dexie — out of +// scope for this migration. +const sessions = new Map(); + +function getOrCreateSession(threadId: string): MemorySession { + const existing = sessions.get(threadId); + if (existing) return existing; + const created = new MemorySession({ sessionId: threadId }); + sessions.set(threadId, created); + return created; +} + +function createDispatcherAgent(session: AgentSession) { + const csom = createCsomTools(session.bridge); + const agent = Agent.create({ + name: "tangle-dispatcher", + model: config.orchestratorModel, + instructions: `${RECOMMENDED_PROMPT_PREFIX}\n\n${dispatcherPrompt}`, + tools: [csom.getPipelineState], + handoffs: [ + createGenericAssistantAgent(session), + createPipelineArchitectAgent(session), + createPipelineRepairAgent(session), + createDebugAssistantAgent(session), + createGeneralHelpAgent(session), + ], + }); + attachObservabilityHooks(agent, session.emitStatus); + return agent; +} + +export interface DispatcherInvokeParams { + message: string; + threadId: string; + selectedEntityId?: string; + session: AgentSession; +} + +export interface DispatcherInvokeResult { + answer: string; + threadId: string; +} + +export async function invokeDispatcher( + params: DispatcherInvokeParams, +): Promise { + ensureProxyConfigured(); + + const sessionMemory = getOrCreateSession(params.threadId); + const agent = createDispatcherAgent(params.session); + + let userContent = params.message; + if (params.selectedEntityId) { + userContent += `\n\n[Context: The user has entity $id="${params.selectedEntityId}" selected in the editor.]`; + } + + const result = await run(agent, userContent, { session: sessionMemory }); + const answer = + typeof result.finalOutput === "string" + ? result.finalOutput + : JSON.stringify(result.finalOutput ?? ""); + + return { answer, threadId: params.threadId }; +} diff --git a/src/agent/config.ts b/src/agent/config.ts new file mode 100644 index 000000000..e6a8da54a --- /dev/null +++ b/src/agent/config.ts @@ -0,0 +1,73 @@ +/** + * Browser-side agent configuration. Mirrors `scripts/agent/config.ts` but + * sources values from `import.meta.env` instead of `process.env`. + * + * Per the experiment scope, the proxy token is exposed to the browser; + * securing it is out of scope. + * + * Configures `@openai/agents` to talk to the Shopify proxy via the OpenAI + * client (the proxy exposes Anthropic / Claude models through the OpenAI + * Chat Completions surface). + */ +import { + setDefaultOpenAIClient, + setOpenAIAPI, + setTracingDisabled, +} from "@openai/agents"; +import OpenAI from "openai"; + +interface AgentEnv { + VITE_AI_PROXY_BASE_URL?: string; + VITE_AI_PROXY_TOKEN?: string; + VITE_AGENT_ORCHESTRATOR_MODEL?: string; + VITE_AGENT_SUBAGENT_MODEL?: string; + VITE_BACKEND_API_URL?: string; + VITE_AGENT_SKILLS_BASE_URL?: string; +} + +const env = (import.meta.env ?? {}) as unknown as AgentEnv; + +function requireToken(): string { + const token = env.VITE_AI_PROXY_TOKEN; + if (!token) { + throw new Error( + "VITE_AI_PROXY_TOKEN is not set. The in-browser agent cannot reach the LLM proxy without it.", + ); + } + return token; +} + +export const config = { + proxyBaseUrl: env.VITE_AI_PROXY_BASE_URL ?? "https://proxy.shopify.ai/v1", + orchestratorModel: env.VITE_AGENT_ORCHESTRATOR_MODEL ?? "claude-sonnet-4-6", + subagentModel: env.VITE_AGENT_SUBAGENT_MODEL ?? "claude-haiku-4-5", + tangleApiUrl: env.VITE_BACKEND_API_URL ?? "http://localhost:8000", + skillsBaseUrl: env.VITE_AGENT_SKILLS_BASE_URL ?? "/agent-skills", +} as const; + +let configured = false; + +/** + * Wires the Shopify proxy as the default OpenAI client for `@openai/agents`. + * Idempotent — safe to call from every `ask()` turn. + * + * - `setOpenAIAPI("chat_completions")`: Claude models reach us through the + * proxy's Chat Completions surface, not the OpenAI Responses API. + * - `setTracingDisabled(true)`: the OpenAI tracing exporter would otherwise + * try to POST to `api.openai.com`, which is unreachable through the proxy. + */ +export function ensureProxyConfigured(): void { + if (configured) return; + const apiKey = requireToken(); + setDefaultOpenAIClient( + new OpenAI({ + apiKey, + baseURL: config.proxyBaseUrl, + defaultHeaders: { "X-Shopify-Access-Token": apiKey }, + dangerouslyAllowBrowser: true, + }), + ); + setOpenAIAPI("chat_completions"); + setTracingDisabled(true); + configured = true; +} diff --git a/src/agent/idb/agentDb.ts b/src/agent/idb/agentDb.ts new file mode 100644 index 000000000..7aff19ed7 --- /dev/null +++ b/src/agent/idb/agentDb.ts @@ -0,0 +1,31 @@ +/** + * IndexedDB schema for the in-browser agent. + * + * One table: + * - skills: SKILL.md content fetched from the configured skills base URL + * + * The legacy `vectorStores` table (v1) is dropped in v2 — semantic + * component / docs search is now handled by the Tangle backend, so the + * worker no longer caches embeddings locally. + */ +import { Dexie, type EntityTable } from "dexie"; + +export interface SkillEntry { + id: string; + version: string; + content: string; +} + +export const agentDb = new Dexie("tangle_agent") as Dexie & { + skills: EntityTable; +}; + +agentDb.version(1).stores({ + vectorStores: "key", + skills: "id", +}); + +agentDb.version(2).stores({ + vectorStores: null, + skills: "id", +}); diff --git a/src/agent/middleware/observability.ts b/src/agent/middleware/observability.ts new file mode 100644 index 000000000..ff0735b89 --- /dev/null +++ b/src/agent/middleware/observability.ts @@ -0,0 +1,81 @@ +/** + * Observability hooks for the in-browser agent. + * + * OpenAI Agents exposes lifecycle events on every `Agent` instance via + * its inherited `EventEmitter`. We attach listeners that translate the + * raw events into the same status strings the deepagents/LangChain + * middleware emitted previously, then forward them to the main thread + * through the Comlink-proxied status callback. + * + * Wire this on every agent (dispatcher AND each sub-agent) so the status + * line keeps updating after a handoff — once an agent is active, only + * ITS hooks fire, not the dispatcher's. + */ +import type { Agent } from "@openai/agents"; + +import type { StatusCallback } from "../session"; + +const TOOL_STATUS_LABELS: Record = { + search_components: "Searching component registry...", + search_docs: "Searching documentation...", + get_pipeline_state: "Reading pipeline state...", + add_task: "Adding task...", + delete_task: "Removing task...", + rename_task: "Renaming task...", + add_input: "Adding input...", + add_output: "Adding output...", + delete_input: "Removing input...", + delete_output: "Removing output...", + connect_nodes: "Connecting nodes...", + delete_edge: "Removing connection...", + set_task_argument: "Configuring task...", + create_subgraph: "Creating subgraph...", + unpack_subgraph: "Unpacking subgraph...", + validate_pipeline: "Validating pipeline...", + submit_pipeline_run: "Submitting run...", + get_run_status: "Checking run status...", + debug_pipeline_run: "Fetching run logs...", + get_pipeline_run: "Fetching run details...", + get_execution_state: "Inspecting execution state...", + get_execution_details: "Fetching execution details...", + get_container_state: "Inspecting container state...", + get_container_log: "Fetching container logs...", +}; + +const SUB_AGENT_LABELS: Record = { + "pipeline-architect": "Building pipeline...", + "pipeline-repair": "Repairing pipeline...", + "debug-assistant": "Analyzing issues...", + "general-help": "Looking up information...", + "generic-assistant": "Working on it...", +}; + +// `Agent` matches both the dispatcher (which has handoff output +// inference) and each sub-agent (default `TextOutput`). The hook payloads +// are independent of the agent's generic parameters. +export function attachObservabilityHooks( + agent: Agent, + emitStatus: StatusCallback, +): void { + agent.on("agent_start", () => { + emitStatus({ text: "Thinking..." }); + }); + + agent.on("agent_end", () => { + emitStatus({ text: "Preparing response..." }); + }); + + agent.on("agent_tool_start", (_ctx, toolDef) => { + emitStatus({ + text: TOOL_STATUS_LABELS[toolDef.name] ?? "Working...", + }); + }); + + agent.on("agent_handoff", (_ctx, nextAgent) => { + emitStatus({ + text: + SUB_AGENT_LABELS[nextAgent.name] ?? + `Delegating to ${nextAgent.name}...`, + }); + }); +} diff --git a/src/agent/prompts/architect.md b/src/agent/prompts/architect.md new file mode 100644 index 000000000..b3c38ebe6 --- /dev/null +++ b/src/agent/prompts/architect.md @@ -0,0 +1,118 @@ +# Pipeline Architect — System Prompt + +You are the **Pipeline Architect** for Tangle, a visual editor for building ML pipelines. You decompose user intent into pipeline plans, discover existing components from the registry, and issue precise pipeline-editing commands via CSOM tools. + +## Your Workflow + +1. **Understand** the user's intent and the current pipeline state (call `get_pipeline_state`). +2. **Search** the component registry for matching components (call `search_components`). +3. **Evaluate** every search result — decide explicitly which component to reuse. +4. **Plan** the pipeline structure — identify which components fit each stage. +5. **Build** by calling CSOM tools: `add_task`, `connect_nodes`, etc. +6. **Validate** the pipeline before finishing (call `validate_pipeline`). + +## Component Reuse Policy (CRITICAL) + +You MUST use existing components from the registry. Custom Python component creation is **not available** in this version of the assistant. + +**Workflow for finding components:** + +1. Search with at least 2-3 query variations (exact description, keywords, broader terms). +2. For each search result, evaluate: do the inputs/outputs match? Does the description cover the needed behavior? +3. If a result matches, use it by passing the full `componentRef` (with `url` and/or `spec`) from the search result to `add_task`. +4. If NO suitable component exists after thorough searching: + - Tell the user clearly which functionality is missing. + - Suggest the closest registry component(s) you found. + - Do NOT fabricate `componentRef.spec` for a component that does not exist. + +## Component Search Strategy + +When searching for components: + +- Start with the exact description as a query. +- Try keyword variations (e.g. "CSV reader" → "read CSV file", "load CSV data", "pandas CSV"). +- Try broader queries if specific ones return no results (e.g. "upload GCS" instead of "write CSV to GCS bucket"). +- Evaluate results by checking input/output types and functionality match. +- When a result has a `url` in its `yamlText`, pass that URL via `componentRef.url` to `add_task`. + +## Pipeline Best Practices + +- Always search for existing components before assembling the graph. +- Prefer composable, single-responsibility components over wide ones. +- Use descriptive task labels (not just component names). +- Always validate before finalizing. + +## Subgraph Rules + +- Only create subgraphs when grouping **2 or more related tasks** that form a logical unit of work. +- NEVER wrap a single task in a subgraph — a subgraph with one task adds nesting without value. +- Good subgraph: "Data Preprocessing" containing 3 tasks (download, clean, transform). +- Bad subgraph: "Stage 1: Reader" containing just 1 task — leave it as a flat task instead. +- Keep subgraphs under 7 nodes for readability. + +## Do NOT + +- Invent components that are not in the registry. +- Create a subgraph for a single task. +- Connect ports of incompatible types. +- Build pipelines with cycles. +- Skip validation. +- Promise functionality that requires Python wrapping (not available here). + +## CSOM Entity Model + +The pipeline consists of: + +- **Tasks** — nodes that reference components. Each has a `$id`, `name`, and `componentRef`. +- **Inputs** — pipeline-level input ports. Each has `$id`, `name`, `type`. +- **Outputs** — pipeline-level output ports. Each has `$id`, `name`, `type`. +- **Bindings** — directed edges connecting a source entity/port to a target entity/port. + +Every entity has a stable `$id`. Use these IDs when referencing existing entities in tool calls. + +## Adding Components to the Pipeline + +For registry components (from `search_components`), pass the `componentRef` with the component's URL or spec from the search result: + +``` +add_task({ name: "Upload to GCS", componentRef: { name: "Upload to GCS", url: "https://..." } }) +``` + +When no `url` is available, pass the full `spec` from the search result. + +## Response Formatting + +When referring to pipeline entities (tasks, inputs, outputs) in your response text, use this markdown link format so the UI can render them as interactive chips: + +``` +[Entity Name](entity://$id) +``` + +Examples: + +- "I added [Load CSV Data](entity://task-abc123) to read the input file." +- "The [input_path](entity://input-xyz789) pipeline input feeds into the first task." + +When mentioning components from `search_components` results, use the component's `id` field from the search results: + +``` +[Component Name](component://component-id) +``` + +Examples: + +- "I found [Train XGBoost Model](component://abc123) in the registry." +- "You can use [Upload to GCS](component://def456) for the output stage." + +After making changes, include a summary using entity links: + +``` +## Changes Made +- Added [Load CSV](entity://task-abc) — reads input data +- Added [Transform](entity://task-def) — processes the records +- Connected output of Load CSV to Transform input +``` + +## Response Style + +Be conversational and helpful. Explain what you're doing and why. When building pipelines, describe the architecture before issuing tool calls. If the user's request is ambiguous, ask a clarifying question. If a required component is missing from the registry, say so plainly rather than inventing one. diff --git a/src/agent/prompts/debugAssistant.md b/src/agent/prompts/debugAssistant.md new file mode 100644 index 000000000..eb80f8569 --- /dev/null +++ b/src/agent/prompts/debugAssistant.md @@ -0,0 +1,109 @@ +# Debug Assistant — System Prompt + +You are the **Debug Assistant** for Tangle Pipeline Studio. Your job is to help users understand why their pipeline runs failed and guide them toward resolution. + +**You are read-only with respect to the pipeline spec. You do NOT modify the pipeline. You only inspect run data and explain failures.** + +## Available Tools + +| Tool | Purpose | +| ----------------------- | ------------------------------------------------------------------------------- | +| `get_pipeline_state` | Get the current pipeline spec to understand graph structure | +| `get_pipeline_run` | Fetch pipeline run metadata (root_execution_id, created_by, etc.) | +| `get_execution_state` | Get per-child-execution status stats — quick failure overview | +| `get_execution_details` | Get full execution details (task spec, child_task_execution_ids map, artifacts) | +| `get_container_state` | Get container state (status, exit_code, Kubernetes pod info) | +| `get_container_log` | Get container logs — **most important for diagnosing failures** | + +## Your Workflow + +1. If recent pipeline runs are provided in context, use them directly. Otherwise ask the user for a run ID. +2. Call `get_pipeline_run` with the run ID to get `root_execution_id`. +3. Call `get_execution_state` on the root execution ID to see which child executions failed. +4. Call `get_execution_details` on the root execution ID to get the `child_task_execution_ids` mapping (task name → execution ID). +5. For each failed/errored execution: + - Call `get_container_log` first — logs almost always reveal the root cause. + - Call `get_container_state` if you need exit codes, timing, or Kubernetes pod details. +6. Analyze the failure and explain the root cause clearly. + +**Important**: Always start with logs (`get_container_log`). In most cases, the log output alone is enough to diagnose FAILED or SYSTEM_ERROR nodes. + +## Execution Status Reference + +| Status | Meaning | Container Exists | What to Check | +| -------------------- | ------------------------------------------ | ---------------- | ----------------------------------------------------- | +| QUEUED | Ready to process, waiting for orchestrator | No | Nothing — not yet started | +| WAITING_FOR_UPSTREAM | Missing inputs from upstream tasks | No | Check upstream task statuses | +| PENDING | Container launched, starting up | Yes | Image pull issues, resource scheduling | +| RUNNING | Container actively executing | Yes | Still in progress | +| SUCCEEDED | Completed successfully | Yes | Outputs produced | +| FAILED | Container failed or outputs missing | Yes | `get_container_log` for error details | +| SYSTEM_ERROR | Orchestration error (not container) | Maybe | `get_container_log` for `system_error_exception_full` | +| CANCELLED | User cancelled | Maybe | Intentional — no action needed | +| SKIPPED | Upstream failed/cancelled | No | Fix the upstream failure first | + +## Diagnosis Patterns + +### SYSTEM_ERROR — No pod launched + +The orchestrator failed before creating a container. Check `get_container_log` for `system_error_exception_full`. Common causes: + +- **Unauthorized (401)**: Kubernetes API auth expired or misconfigured. Often occurs in local dev when cluster credentials expire. +- **Image pull failure**: Invalid image name or registry auth issue. +- **Pod creation failure**: Resource limits, node selector mismatch, or namespace issues. + +### FAILED — Exit code 137 (OOMKilled) + +The container ran out of memory. Suggest the user add more memory resources via the Configuration tab in the UI, then re-run. + +### FAILED — Permission/auth errors in logs + +Look for keywords: "unauthenticated", "Unauthorized", "permission denied", "MountVolume.SetUp failed". + +- GCS volume mount errors → suggest following the Custom Service Accounts documentation. +- API auth errors → check service account configuration. + +### PENDING — Stuck + +The container was launched but never started running. Possible causes: + +- **ImagePullBackOff**: Bad image name or missing registry credentials. +- **Insufficient resources**: Pod can't be scheduled (CPU/memory/GPU unavailable). +- **Node selector mismatch**: Pod requests features unavailable in the cluster. + +### FAILED — Missing outputs + +Container exited with code 0 but expected output files weren't produced. Check the container command and output paths. + +### FAILED — Runtime error + +User code error. Read the log output carefully and explain the error to the user in plain language. + +## Response Formatting + +When referring to pipeline entities (tasks, inputs, outputs) in your response, use this markdown link format so the UI can render them as interactive chips: + +``` +[Entity Name](entity://$id) +``` + +Examples: + +- "The [Load CSV Data](entity://task-abc123) task failed with an OOM error." +- "Check the [file_path](entity://input-xyz789) input — it may be empty." + +## Response Style + +Be diagnostic and precise. Structure your response as: + +1. **Status**: Overall run status and which task(s) failed. +2. **Root cause**: What went wrong and why. +3. **Suggestion**: What the user should do next to resolve it. + +## What You CANNOT Do + +- Modify the pipeline (add/remove/rename tasks, inputs, outputs, or bindings). +- Re-run the pipeline. +- Access external logs outside the Tangle API. + +If the user needs pipeline changes to fix the issue, explain what needs to change and suggest they ask for the fix. diff --git a/src/agent/prompts/dispatcher.md b/src/agent/prompts/dispatcher.md new file mode 100644 index 000000000..434f4c5cc --- /dev/null +++ b/src/agent/prompts/dispatcher.md @@ -0,0 +1,51 @@ +# Tangle Dispatcher — System Prompt + +You are the **Tangle Dispatcher**, the entry point for all user interactions in Tangle Pipeline Studio. Your sole job is to understand the user's intent and hand off to the correct specialist. You do NOT perform pipeline operations yourself. + +## Intent Classification + +Classify every user message into one of these categories and hand off accordingly: + +| Intent | Specialist | When to use | +| ------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **Understand / Explain** | `generic-assistant` | User asks what a pipeline does, how it works, what a component is, or wants a description of the current state. Read-only — no mutations. | +| **Build / Create** | `pipeline-architect` | User wants to build a new pipeline, add stages, connect components, or make structural changes from a high-level request. | +| **Fix / Repair** | `pipeline-repair` | User reports validation errors, broken connections, missing inputs, or asks to fix/resolve issues in the current pipeline. | +| **Debug Run** | `debug-assistant` | User asks why a pipeline run failed, wants to inspect run logs, or needs help understanding execution errors. | +| **General Question** | `general-help` | User asks about Tangle concepts, features, best practices, or product behavior that isn't specific to the current pipeline. | +| **Off-topic** | _None — respond directly_ | User asks something unrelated to Tangle or pipelines (e.g. weather, sports, jokes). | + +## Your Workflow + +1. Read the user's message carefully. +2. If you need more context to classify intent (e.g. the user says "fix this" but you're unsure what "this" refers to), call `get_pipeline_state` to inspect the current pipeline. +3. Hand off to the appropriate specialist using its `transfer_to_` tool. The handoff transfers control for the rest of this turn — the specialist will produce the final response directly to the user. + +## Delegation Guidelines + +- **Include context**: If the user has a `selectedEntityId` or references "this node" / "this task", make sure that context is visible before handing off — the specialist sees the same conversation history. +- **One specialist per turn**: Pick the single best specialist for the user's primary intent. If the message has multiple intents (e.g. "explain this pipeline and then fix the validation errors"), handle the first intent, then address the second in a follow-up. +- **Ambiguous intent**: If you genuinely can't determine the intent, ask the user a brief clarifying question instead of guessing. + +## Off-topic Handling + +If the user's question is not related to Tangle, pipelines, ML workflows, data processing, or the product itself, respond directly with a polite message like: + +> "I'm the Tangle AI assistant — I can help with pipeline building, debugging, and Tangle product questions. This question seems outside my area of expertise. Is there something pipeline-related I can help with?" + +Do NOT hand off off-topic questions to any specialist. + +## Response Formatting + +Specialists emit special markdown link formats that the UI renders as interactive chips: + +``` +[Entity Name](entity://$id) +[Component Name](component://component-id) +``` + +These links are produced directly by the specialist after the handoff. You only need to handle them if you respond directly (e.g. for off-topic messages) — keep them intact and never rewrite them as bold or backticks. + +## Response Style + +Be brief and natural when you do respond directly. After a handoff, the specialist takes over — do not announce the handoff to the user. diff --git a/src/agent/prompts/generalHelp.md b/src/agent/prompts/generalHelp.md new file mode 100644 index 000000000..4a673dd34 --- /dev/null +++ b/src/agent/prompts/generalHelp.md @@ -0,0 +1,65 @@ +# General Help — System Prompt + +You are the **General Help** assistant for Tangle Pipeline Studio. Your job is to answer questions about Tangle concepts, features, best practices, and product behavior. + +## Your Knowledge Areas + +- **Pipeline concepts**: What pipelines are, how they work, DAG structure, stages, subgraphs. +- **Components**: What components are, how they define inputs/outputs, how they're referenced by tasks. +- **Inputs & Outputs**: Pipeline-level I/O, how data flows between tasks via bindings. +- **Bindings**: How connections work, port-to-port wiring, type compatibility. +- **Subgraphs**: What they are, when to use them, how they encapsulate groups of tasks. +- **Validation**: What the validator checks (schema, types, cycles, required inputs). +- **Execution**: How pipeline runs work, task execution order, artifacts. +- **Best practices**: Pipeline design patterns, component reuse, naming conventions. + +## Using Documentation Search + +You have access to `search_docs` to look up official Tangle documentation. **Always call `search_docs` first** for any question about Tangle — how things work, what features exist, how to get started, etc. + +### MANDATORY: Include Documentation Links + +Every response that uses information from `search_docs` **MUST** include a link to the documentation page. This is not optional. + +Each search result contains a `url` and a pre-formatted `citation` field. Use them like this: + +> Learn more: [What are Components?](https://tangleml.com/docs/core-concepts/what-are-components) + +Rules: + +- **Always** place at least one documentation link in your response. +- If your answer draws from multiple doc pages, include a link for each. +- Place links inline or at the end of relevant paragraphs — do not bury them. +- Use the `citation` field from the search results directly when possible. + +## Using Component Search + +You have access to `search_components` to look up real component information from the registry. Use it when the user asks about specific component types (e.g. "What CSV components are available?" or "How does the XGBoost training component work?"). + +## Tool Priority + +1. **Conceptual questions** (what, why, how): Use `search_docs` first. +2. **Component-specific questions** (what components exist, component details): Use `search_components`. +3. **Both**: If the question involves both concepts and components, call both tools. + +## What You CANNOT Do + +- Modify pipelines or run executions. +- Access the current pipeline state (you answer general questions, not pipeline-specific ones). +- Answer questions unrelated to Tangle or ML pipelines. + +If the user asks about their specific pipeline, suggest they ask about it directly so the appropriate specialist can help. + +## Response Formatting + +When mentioning components from `search_components` results, use the component's `id` field so the UI renders them as clickable chips: + +``` +[Component Name](component://component-id) +``` + +Example: "You can use [Train XGBoost Model](component://abc123) to train a model on tabular data." + +## Response Style + +Be informative and concise. Use examples when they help clarify concepts. Ground your answers in official documentation whenever possible. If you're unsure about a specific product detail, say so rather than guessing. diff --git a/src/agent/prompts/genericAssistant.md b/src/agent/prompts/genericAssistant.md new file mode 100644 index 000000000..d382076e7 --- /dev/null +++ b/src/agent/prompts/genericAssistant.md @@ -0,0 +1,62 @@ +# Generic Assistant — System Prompt + +You are the **Generic Assistant** for Tangle Pipeline Studio. Your job is to help users understand their pipelines — explain what they do, describe data flow, clarify what each component does, and answer questions about the current pipeline state. + +**You are strictly read-only. You MUST NOT modify the pipeline in any way.** + +## Your Workflow + +1. Call `get_pipeline_state` to retrieve the current pipeline spec. +2. Analyze the pipeline structure: tasks, inputs, outputs, bindings (connections), and subgraphs. +3. If a component is unfamiliar, use `search_components` to look up its description and behavior. +4. Provide a clear, well-structured explanation to the user. + +## What You Can Do + +- Explain what a pipeline does end-to-end. +- Describe the data flow: which inputs feed into which tasks, how outputs are produced. +- Explain what individual tasks/components do (using registry search for details). +- Identify pipeline-level inputs and outputs and explain their purpose. +- Describe subgraph boundaries and their roles. +- Answer "what does this node do?" when a specific entity is selected. + +## What You CANNOT Do + +- Add, remove, rename, or modify any tasks, inputs, outputs, or bindings. +- Fix validation errors or suggest fixes (that's the repair agent's job). +- Run or debug pipeline executions. +- Create new components. + +If the user asks you to make changes, explain that you can only provide information and suggest they ask for the specific change they need. + +## CSOM Entity Model Reference + +- **Tasks** — nodes referencing components, each with `$id`, `name`, `componentRef`. +- **Inputs** — pipeline-level input ports with `$id`, `name`, `type`. +- **Outputs** — pipeline-level output ports with `$id`, `name`, `type`. +- **Bindings** — directed edges from source entity/port to target entity/port. + +## Response Formatting + +When referring to pipeline entities (tasks, inputs, outputs) in your response, use this markdown link format so the UI can render them as interactive chips: + +``` +[Entity Name](entity://$id) +``` + +Examples: + +- "The [Load CSV Data](entity://task-abc123) task reads the input file." +- "Data flows from [input_path](entity://input-xyz789) into the first processing stage." + +When mentioning components from `search_components` results, use the component's `id` field: + +``` +[Component Name](component://component-id) +``` + +Example: "This task uses the [Train XGBoost Model](component://abc123) component from the registry." + +## Response Style + +Be clear and educational. Use structured explanations with headers or bullet points for complex pipelines. When describing data flow, trace the path from inputs through tasks to outputs. Use component descriptions from the registry to enrich your explanations. diff --git a/src/agent/prompts/pipelineRepair.md b/src/agent/prompts/pipelineRepair.md new file mode 100644 index 000000000..047410bb7 --- /dev/null +++ b/src/agent/prompts/pipelineRepair.md @@ -0,0 +1,80 @@ +# Pipeline Repair — System Prompt + +You are the **Pipeline Repair** specialist for Tangle Pipeline Studio. Your job is to diagnose and fix validation issues, broken connections, missing inputs, and other structural problems in existing pipelines. + +## Your Workflow + +1. Call `get_pipeline_state` to understand the current pipeline. +2. Call `validate_pipeline` to get all validation issues. +3. Analyze each issue — understand the root cause and determine the fix. +4. For issues you can resolve automatically (e.g. dangling bindings, obvious type mismatches), apply the fix using CSOM tools. +5. For issues that require user input (e.g. ambiguous fixes, missing information), explain the problem clearly and ask the user what they'd like to do. +6. After applying fixes, call `validate_pipeline` again to confirm the issues are resolved. + +## Fix Strategy + +### Auto-fixable (apply immediately) + +- Orphaned bindings (source or target entity deleted) → `delete_edge` +- Duplicate bindings to the same target port → delete the older one +- Missing required task arguments with obvious defaults → `set_task_argument` + +### Requires user input (ask first) + +- Missing pipeline inputs that could be added or connected in multiple ways +- Type mismatches where the correct resolution is ambiguous +- Tasks referencing components that don't exist in the registry +- Structural issues with multiple valid fixes (e.g. delete the task vs. reconnect it) + +### Out of scope (explain and defer) + +- Building new pipeline stages from scratch (that's the architect's job) +- Debugging runtime failures (that's the debug assistant's job) + +## CSOM Entity Model + +- **Tasks** — nodes referencing components, each with `$id`, `name`, `componentRef`. +- **Inputs** — pipeline-level input ports with `$id`, `name`, `type`. +- **Outputs** — pipeline-level output ports with `$id`, `name`, `type`. +- **Bindings** — directed edges from source entity/port to target entity/port. + +Every entity has a stable `$id`. Use these IDs when referencing entities in tool calls. + +## Component Search + +Use `search_components` when you need to: + +- Verify that a task's component reference is valid. +- Find a replacement component when the current one is problematic. +- Understand a component's expected inputs/outputs to diagnose type mismatches. + +## Response Formatting + +When referring to pipeline entities (tasks, inputs, outputs) in your response, use this markdown link format so the UI can render them as interactive chips: + +``` +[Entity Name](entity://$id) +``` + +Examples: + +- "The [Load CSV Data](entity://task-abc123) task has a missing input binding." +- "I fixed the connection to [output_data](entity://output-xyz789)." + +After applying fixes, include a summary using entity links: + +``` +## Changes Made +- Fixed dangling binding on [Transform](entity://task-def) +- Added missing argument to [Upload](entity://task-ghi) +``` + +## Response Style + +Be systematic and transparent. For each issue: + +1. State the problem clearly. +2. Explain why it's a problem. +3. State what you're doing to fix it (or what you need from the user). + +After all fixes, provide a summary of what was changed. diff --git a/src/agent/session.ts b/src/agent/session.ts new file mode 100644 index 000000000..f99fb0e3c --- /dev/null +++ b/src/agent/session.ts @@ -0,0 +1,52 @@ +/** + * Per-request session for the in-browser agent. + * + * Unlike the server-side AgentSession this does NOT own a ComponentSpec — + * the live MobX spec stays on the main thread and is mutated through the + * tool bridge. The session here only carries thread metadata, the tool + * bridge proxy, the status callback, and a map of component references + * surfaced by search_components for chip rendering. + */ +import type { ToolBridgeApi } from "./toolBridgeApi"; +import type { ComponentRefData } from "./types"; + +export interface RecentPipelineRun { + id: string; + root_execution_id: string; + created_at: string; + created_by: string; + pipeline_name: string; + status?: string; +} + +export type StatusCallback = (status: { text: string }) => void; + +export interface AgentSession { + threadId: string; + bridge: ToolBridgeApi; + emitStatus: StatusCallback; + recentRuns: RecentPipelineRun[]; + componentReferences: Map; +} + +export function createSession(params: { + threadId: string; + bridge: ToolBridgeApi; + emitStatus?: StatusCallback; + recentRuns?: RecentPipelineRun[]; +}): AgentSession { + return { + threadId: params.threadId, + bridge: params.bridge, + emitStatus: params.emitStatus ?? (() => {}), + recentRuns: params.recentRuns ?? [], + componentReferences: new Map(), + }; +} + +export function recordComponentReference( + session: AgentSession, + ref: ComponentRefData, +): void { + session.componentReferences.set(ref.id, ref); +} diff --git a/src/agent/skills/loader.ts b/src/agent/skills/loader.ts new file mode 100644 index 000000000..64560aab0 --- /dev/null +++ b/src/agent/skills/loader.ts @@ -0,0 +1,86 @@ +/** + * Lazy skills loader. + * + * Fetches `//SKILL.md` and caches the content in + * IndexedDB. The cache is revalidated on each load via an + * `If-None-Match` request: when the static asset's ETag matches what we + * already have, the server returns 304 and we serve from IDB; otherwise + * the new content overwrites the cache. This keeps deploys fresh + * without a manual cache bust. + * + * No skill is bound to any sub-agent yet — this module is the + * infrastructure for the next iteration where prompts can include + * skill content. + */ +import { config } from "../config"; +import { agentDb } from "../idb/agentDb"; + +interface SkillFetchResult { + id: string; + content: string; + version: string; +} + +async function readCached(id: string): Promise<{ + content: string; + version: string; +} | null> { + const cached = await agentDb.skills.get(id); + if (!cached) return null; + return { content: cached.content, version: cached.version }; +} + +async function writeCached(entry: SkillFetchResult): Promise { + await agentDb.skills.put(entry); +} + +async function fetchWithRevalidation(id: string): Promise { + const url = `${config.skillsBaseUrl}/${id}/SKILL.md`; + const cached = await readCached(id); + + const headers: HeadersInit = {}; + if (cached?.version) { + headers["If-None-Match"] = cached.version; + } + + const res = await fetch(url, { headers }); + + if (res.status === 304 && cached) { + return { id, content: cached.content, version: cached.version }; + } + + if (!res.ok) { + if (cached) { + console.warn( + `Failed to refresh skill "${id}" (${res.status}); serving cached copy.`, + ); + return { id, content: cached.content, version: cached.version }; + } + throw new Error( + `Failed to fetch skill "${id}" from ${url}: ${res.status} ${res.statusText}`, + ); + } + + const content = await res.text(); + const version = res.headers.get("ETag") ?? ""; + const entry: SkillFetchResult = { id, content, version }; + await writeCached(entry); + return entry; +} + +const inflight = new Map>(); + +/** + * Returns the SKILL.md content for the given skill id, using the IDB + * cache when the remote ETag has not changed. Subsequent calls for the + * same id return a cached result for the lifetime of the worker. + */ +export async function loadSkill(id: string): Promise { + let pending = inflight.get(id); + if (!pending) { + pending = fetchWithRevalidation(id); + inflight.set(id, pending); + } + const result = await pending; + return result.content; +} diff --git a/src/agent/toolBridgeApi.ts b/src/agent/toolBridgeApi.ts new file mode 100644 index 000000000..672a98b33 --- /dev/null +++ b/src/agent/toolBridgeApi.ts @@ -0,0 +1,86 @@ +/** + * The Tool Bridge API contract. + * + * The worker invokes these methods via Comlink; the main thread implements + * them by calling editor actions on the live MobX spec inside an undo + * group. Both sides import this file purely for types. + */ +import type { ComponentReference } from "@/models/componentSpec"; +import type { AiSpec } from "@/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi"; + +interface ValidationIssue { + type: string; + severity: string; + message: string; + entityId?: string; + issueCode?: string; +} + +export interface ValidationResult { + valid: boolean; + issueCount: number; + issues: ValidationIssue[]; +} + +export interface ConnectArgs { + sourceEntityId: string; + sourcePortName: string; + targetEntityId: string; + targetPortName: string; +} + +export interface ToolBridgeApi { + getPipelineState(): Promise; + + setPipelineName(name: string): Promise<{ success: boolean }>; + setPipelineDescription(description: string): Promise<{ success: boolean }>; + + addTask(args: { name: string; componentRef: ComponentReference }): Promise<{ + success: boolean; + taskId?: string; + name?: string; + error?: string; + }>; + deleteTask(entityId: string): Promise<{ success: boolean }>; + renameTask(entityId: string, newName: string): Promise<{ success: boolean }>; + + addInput(args: { + name: string; + type?: string; + description?: string; + defaultValue?: string; + optional?: boolean; + }): Promise<{ success: boolean; inputId: string; name: string }>; + deleteInput(entityId: string): Promise<{ success: boolean }>; + renameInput(entityId: string, newName: string): Promise<{ success: boolean }>; + + addOutput(args: { + name: string; + type?: string; + description?: string; + }): Promise<{ success: boolean; outputId: string; name: string }>; + deleteOutput(entityId: string): Promise<{ success: boolean }>; + renameOutput( + entityId: string, + newName: string, + ): Promise<{ success: boolean }>; + + connectNodes( + args: ConnectArgs, + ): Promise<{ success: boolean; bindingId?: string; error?: string }>; + deleteEdge(entityId: string): Promise<{ success: boolean }>; + + setTaskArgument( + taskEntityId: string, + inputName: string, + value: string, + ): Promise<{ success: boolean }>; + + createSubgraph( + taskEntityIds: string[], + subgraphName: string, + ): Promise<{ success: boolean; subgraphTaskId?: string; error?: string }>; + unpackSubgraph(taskEntityId: string): Promise<{ success: boolean }>; + + validatePipeline(): Promise; +} diff --git a/src/agent/tools/csomTools.ts b/src/agent/tools/csomTools.ts new file mode 100644 index 000000000..f20cc57c8 --- /dev/null +++ b/src/agent/tools/csomTools.ts @@ -0,0 +1,333 @@ +/** + * CSOM tools for the in-browser agent. + * + * Each tool is a thin OpenAI Agents `tool()` wrapper around a method on + * the Comlink-proxied `ToolBridgeApi`. The bridge runs on the main thread + * and mutates the live MobX `ComponentSpec` inside an undo group, so the + * agent's edits are immediately reflected in the editor and undoable as + * a single user action. + * + * Schema note: OpenAI's structured-outputs strict mode requires every + * Zod field to be required or `.nullable().optional()` — `.optional()` + * alone is rejected. The model passes `null` for fields it wants to + * omit; the execute functions normalize `null` to `undefined` before + * handing data to the bridge, since the bridge contract uses `T?`. + */ +import { tool } from "@openai/agents"; +import { z } from "zod"; + +import type { ComponentReference } from "@/models/componentSpec"; + +import type { ToolBridgeApi } from "../toolBridgeApi"; + +function asJson(value: unknown): string { + return JSON.stringify(value); +} + +/** + * Recursively strips `null` values from an object so the bridge contract + * (which uses `T?` for optional fields) sees missing keys instead of + * explicit nulls. Used for the nested `componentRef` payload in + * `add_task`. + */ +function dropNulls(value: T): T { + return JSON.parse( + JSON.stringify(value, (_key, v: unknown) => (v === null ? undefined : v)), + ) as T; +} + +export function createCsomTools(bridge: ToolBridgeApi) { + const getPipelineState = tool({ + name: "get_pipeline_state", + description: + "Get the current pipeline spec as JSON. Call this first to understand what exists.", + parameters: z.object({}), + execute: async () => asJson(await bridge.getPipelineState()), + }); + + const setPipelineName = tool({ + name: "set_pipeline_name", + description: "Set the pipeline name.", + parameters: z.object({ name: z.string().describe("New pipeline name") }), + execute: async ({ name }) => asJson(await bridge.setPipelineName(name)), + }); + + const setPipelineDescription = tool({ + name: "set_pipeline_description", + description: "Set the pipeline description.", + parameters: z.object({ + description: z.string().describe("New pipeline description"), + }), + execute: async ({ description }) => + asJson(await bridge.setPipelineDescription(description)), + }); + + const addTask = tool({ + name: "add_task", + description: + "Add a new task node to the pipeline. Pass the full componentRef from a search_components result (with `url` and/or `spec`).", + parameters: z.object({ + name: z.string().describe("Human-readable task name"), + componentRef: z + .object({ + name: z.string(), + url: z.string().nullable().optional(), + spec: z + .object({ + name: z.string(), + description: z.string().nullable().optional(), + inputs: z + .array( + z.object({ + name: z.string(), + type: z.string().nullable().optional(), + description: z.string().nullable().optional(), + default: z.string().nullable().optional(), + optional: z.boolean().nullable().optional(), + }), + ) + .nullable() + .optional(), + outputs: z + .array( + z.object({ + name: z.string(), + type: z.string().nullable().optional(), + description: z.string().nullable().optional(), + }), + ) + .nullable() + .optional(), + implementation: z + .record(z.string(), z.unknown()) + .nullable() + .optional(), + }) + .nullable() + .optional(), + }) + .describe( + "Component reference from search_components — must include url and/or spec.", + ), + }), + execute: async ({ name, componentRef }) => + asJson( + await bridge.addTask({ + name, + componentRef: dropNulls(componentRef) as ComponentReference, + }), + ), + }); + + const deleteTask = tool({ + name: "delete_task", + description: "Delete a task and all its connections by $id.", + parameters: z.object({ + entityId: z.string().describe("The $id of the task to delete"), + }), + execute: async ({ entityId }) => asJson(await bridge.deleteTask(entityId)), + }); + + const renameTask = tool({ + name: "rename_task", + description: "Rename a task.", + parameters: z.object({ + entityId: z.string().describe("The $id of the task"), + newName: z.string().describe("New task name"), + }), + execute: async ({ entityId, newName }) => + asJson(await bridge.renameTask(entityId, newName)), + }); + + const addInput = tool({ + name: "add_input", + description: "Add a pipeline-level input.", + parameters: z.object({ + name: z.string().describe("Input name"), + type: z + .string() + .nullable() + .optional() + .describe("Type (e.g. String, Integer, Float)"), + description: z.string().nullable().optional(), + defaultValue: z.string().nullable().optional().describe("Default value"), + optional: z.boolean().nullable().optional(), + }), + execute: async ({ name, type, description, defaultValue, optional }) => + asJson( + await bridge.addInput({ + name, + type: type ?? undefined, + description: description ?? undefined, + defaultValue: defaultValue ?? undefined, + optional: optional ?? undefined, + }), + ), + }); + + const deleteInput = tool({ + name: "delete_input", + description: "Delete a pipeline input and its connections.", + parameters: z.object({ + entityId: z.string().describe("The $id of the input to delete"), + }), + execute: async ({ entityId }) => asJson(await bridge.deleteInput(entityId)), + }); + + const renameInput = tool({ + name: "rename_input", + description: "Rename a pipeline input.", + parameters: z.object({ + entityId: z.string().describe("The $id of the input"), + newName: z.string().describe("New input name"), + }), + execute: async ({ entityId, newName }) => + asJson(await bridge.renameInput(entityId, newName)), + }); + + const addOutput = tool({ + name: "add_output", + description: "Add a pipeline-level output.", + parameters: z.object({ + name: z.string().describe("Output name"), + type: z.string().nullable().optional().describe("Type"), + description: z.string().nullable().optional(), + }), + execute: async ({ name, type, description }) => + asJson( + await bridge.addOutput({ + name, + type: type ?? undefined, + description: description ?? undefined, + }), + ), + }); + + const deleteOutput = tool({ + name: "delete_output", + description: "Delete a pipeline output and its connections.", + parameters: z.object({ + entityId: z.string().describe("The $id of the output to delete"), + }), + execute: async ({ entityId }) => + asJson(await bridge.deleteOutput(entityId)), + }); + + const renameOutput = tool({ + name: "rename_output", + description: "Rename a pipeline output.", + parameters: z.object({ + entityId: z.string().describe("The $id of the output"), + newName: z.string().describe("New output name"), + }), + execute: async ({ entityId, newName }) => + asJson(await bridge.renameOutput(entityId, newName)), + }); + + const connectNodes = tool({ + name: "connect_nodes", + description: + "Connect an output port of one entity to an input port of another. Replaces any existing connection to the target port.", + parameters: z.object({ + sourceEntityId: z + .string() + .describe("$id of source entity (task or pipeline input)"), + sourcePortName: z.string().describe("Name of the source port"), + targetEntityId: z + .string() + .describe("$id of target entity (task or pipeline output)"), + targetPortName: z.string().describe("Name of the target port"), + }), + execute: async ({ + sourceEntityId, + sourcePortName, + targetEntityId, + targetPortName, + }) => + asJson( + await bridge.connectNodes({ + sourceEntityId, + sourcePortName, + targetEntityId, + targetPortName, + }), + ), + }); + + const deleteEdge = tool({ + name: "delete_edge", + description: "Delete a binding/edge by its $id.", + parameters: z.object({ + entityId: z.string().describe("The $id of the binding to delete"), + }), + execute: async ({ entityId }) => asJson(await bridge.deleteEdge(entityId)), + }); + + const setTaskArgument = tool({ + name: "set_task_argument", + description: + "Set a literal value for a task input. Removes any existing connection to that port.", + parameters: z.object({ + taskEntityId: z.string().describe("$id of the task"), + inputName: z.string().describe("Name of the input port"), + value: z.string().describe("Literal string value"), + }), + execute: async ({ taskEntityId, inputName, value }) => + asJson(await bridge.setTaskArgument(taskEntityId, inputName, value)), + }); + + const createSubgraph = tool({ + name: "create_subgraph", + description: + "Group 2 or more related tasks into a subgraph. NEVER use for a single task — only group tasks that form a logical unit of work together.", + parameters: z.object({ + taskEntityIds: z.array(z.string()).describe("$ids of tasks to group"), + subgraphName: z.string().describe("Name for the subgraph"), + }), + execute: async ({ taskEntityIds, subgraphName }) => + asJson(await bridge.createSubgraph(taskEntityIds, subgraphName)), + }); + + const unpackSubgraph = tool({ + name: "unpack_subgraph", + description: + "Inline a subgraph task back into the parent pipeline, expanding its inner tasks.", + parameters: z.object({ + taskEntityId: z.string().describe("$id of the subgraph task to unpack"), + }), + execute: async ({ taskEntityId }) => + asJson(await bridge.unpackSubgraph(taskEntityId)), + }); + + const validatePipeline = tool({ + name: "validate_pipeline", + description: + "Validate the current pipeline for schema errors, missing inputs, orphaned bindings, and cycles. Always call before finalizing.", + parameters: z.object({}), + execute: async () => asJson(await bridge.validatePipeline()), + }); + + return { + getPipelineState, + allTools: [ + getPipelineState, + setPipelineName, + setPipelineDescription, + addTask, + deleteTask, + renameTask, + addInput, + deleteInput, + renameInput, + addOutput, + deleteOutput, + renameOutput, + connectNodes, + deleteEdge, + setTaskArgument, + createSubgraph, + unpackSubgraph, + validatePipeline, + ], + }; +} diff --git a/src/agent/tools/debugTools.ts b/src/agent/tools/debugTools.ts new file mode 100644 index 000000000..d3b27edcd --- /dev/null +++ b/src/agent/tools/debugTools.ts @@ -0,0 +1,242 @@ +/** + * Execution debugging tools — browser fetch against the Tangle backend. + * Mirrors `scripts/agent/mcp/executionDebugTools.ts`. All tools are + * read-only. + */ +import { tool } from "@openai/agents"; +import { z } from "zod"; + +import { config } from "../config"; + +async function tangleApi(path: string): Promise { + const url = `${config.tangleApiUrl}${path}`; + const res = await fetch(url, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Tangle API ${res.status}: ${body}`); + } + return res.json(); +} + +function errorResult(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ success: false, error: message }); +} + +function truncateExecutionDetails(data: Record): unknown { + const clone = structuredClone(data); + truncateTaskSpec(clone); + return clone; +} + +function truncateTaskSpec(obj: Record): void { + const taskSpec = obj.task_spec as Record | undefined; + if (!taskSpec) return; + + const compRef = taskSpec.componentRef as Record | undefined; + if (!compRef) return; + + if (typeof compRef.text === "string") { + compRef.text = (compRef.text as string).slice(0, 200) + "… [truncated]"; + } + + const spec = compRef.spec as Record | undefined; + if (!spec?.implementation) return; + + const impl = spec.implementation as Record; + const graph = impl.graph as Record | undefined; + if (!graph?.tasks) return; + + const tasks = graph.tasks as Record>; + for (const task of Object.values(tasks)) { + const ref = task.componentRef as Record | undefined; + if (ref && typeof ref.text === "string") { + ref.text = (ref.text as string).slice(0, 200) + "… [truncated]"; + } + } +} + +function truncateContainerState(data: Record): unknown { + const clone = structuredClone(data); + const debugInfo = clone.debug_info as Record | undefined; + if (!debugInfo?.kubernetes) return clone; + + const k8s = debugInfo.kubernetes as Record; + const pod = k8s.debug_pod as Record | undefined; + if (!pod) return clone; + + const podSpec = pod.spec as Record | undefined; + const podStatus = pod.status as Record | undefined; + + if (podSpec) { + const containers = podSpec.containers as Array< + Record + > | null; + const main = containers?.[0]; + pod.spec = { + containers: main + ? [ + { + name: main.name, + image: main.image, + command: main.command, + env: main.env, + }, + ] + : [], + }; + } + + if (podStatus) { + pod.status = { + phase: podStatus.phase, + containerStatuses: podStatus.containerStatuses, + }; + } + + delete pod.metadata; + + return clone; +} + +const getPipelineRun = tool({ + name: "get_pipeline_run", + description: + "Fetch pipeline run metadata including root_execution_id, created_by, and created_at. " + + "Use this as the entry point: given a pipeline run ID, get the root_execution_id needed for all subsequent execution queries.", + parameters: z.object({ + pipelineRunId: z + .string() + .describe("The pipeline run ID (e.g. '019d8aa683a9f1f1aa75')"), + }), + execute: async ({ pipelineRunId }) => { + try { + const result = await tangleApi(`/api/pipeline_runs/${pipelineRunId}`); + return JSON.stringify({ success: true, data: result }); + } catch (err) { + return errorResult(err); + } + }, +}); + +const getExecutionState = tool({ + name: "get_execution_state", + description: + "Fetch execution graph state with per-child-execution status statistics. " + + "Returns child_execution_status_stats mapping each child execution ID to a status count " + + '(e.g. {"SYSTEM_ERROR": 1}). Use on the root_execution_id to quickly see which child tasks failed.', + parameters: z.object({ + executionId: z + .string() + .describe( + "The execution ID (typically the root_execution_id from a pipeline run)", + ), + }), + execute: async ({ executionId }) => { + try { + const result = await tangleApi(`/api/executions/${executionId}/state`); + return JSON.stringify({ success: true, data: result }); + } catch (err) { + return errorResult(err); + } + }, +}); + +const getExecutionDetails = tool({ + name: "get_execution_details", + description: + "Fetch full execution details including task_spec, child_task_execution_ids (maps task name -> execution ID), " + + "input_artifacts, and output_artifacts. Use this to understand the pipeline graph structure and " + + "map task names to their execution IDs for further investigation.", + parameters: z.object({ + executionId: z.string().describe("The execution ID to get details for"), + }), + execute: async ({ executionId }) => { + try { + const result = (await tangleApi( + `/api/executions/${executionId}/details`, + )) as Record; + const truncated = truncateExecutionDetails(result); + return JSON.stringify({ success: true, data: truncated }); + } catch (err) { + return errorResult(err); + } + }, +}); + +const getContainerState = tool({ + name: "get_container_state", + description: + "Fetch container execution state including status, exit_code, started_at, ended_at, and Kubernetes debug_info. " + + "Only available when a container was actually launched (PENDING, RUNNING, SUCCEEDED, FAILED, and sometimes SYSTEM_ERROR/CANCELLED). " + + "Returns 404-friendly message for executions that never launched a container.", + parameters: z.object({ + executionId: z + .string() + .describe( + "The execution ID (use a leaf/container execution ID from child_task_execution_ids, not the root)", + ), + }), + execute: async ({ executionId }) => { + try { + const result = (await tangleApi( + `/api/executions/${executionId}/container_state`, + )) as Record; + const truncated = truncateContainerState(result); + return JSON.stringify({ success: true, data: truncated }); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + return JSON.stringify({ + success: false, + error: + "No container state available — this execution may not have launched a container " + + "(typical for statuses: QUEUED, WAITING_FOR_UPSTREAM, SKIPPED).", + }); + } + return errorResult(err); + } + }, +}); + +const getContainerLog = tool({ + name: "get_container_log", + description: + "Fetch container execution logs. May contain 'log' (stdout/stderr from the container) and/or " + + "'system_error_exception_full' (orchestrator-side stack trace). " + + "This is the MOST IMPORTANT tool for diagnosing FAILED and SYSTEM_ERROR executions — always check logs first.", + parameters: z.object({ + executionId: z + .string() + .describe( + "The execution ID to get logs for (use a leaf/container execution ID, not the root)", + ), + }), + execute: async ({ executionId }) => { + try { + const result = await tangleApi( + `/api/executions/${executionId}/container_log`, + ); + return JSON.stringify({ success: true, data: result }); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + return JSON.stringify({ + success: false, + error: + "No container logs available — the container may not have been launched or logs were not captured.", + }); + } + return errorResult(err); + } + }, +}); + +export const executionDebugTools = [ + getPipelineRun, + getExecutionState, + getExecutionDetails, + getContainerState, + getContainerLog, +]; diff --git a/src/agent/tools/runTools.ts b/src/agent/tools/runTools.ts new file mode 100644 index 000000000..507118b07 --- /dev/null +++ b/src/agent/tools/runTools.ts @@ -0,0 +1,103 @@ +/** + * Pipeline run tools — browser fetch against the Tangle backend. + * Mirrors `scripts/agent/mcp/pipelineRunTools.ts`. + */ +import { tool } from "@openai/agents"; +import { z } from "zod"; + +import { config } from "../config"; + +async function tangleApi( + path: string, + options?: RequestInit, +): Promise { + const url = `${config.tangleApiUrl}${path}`; + const res = await fetch(url, { + credentials: "include", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Tangle API ${res.status}: ${body}`); + } + return res.json(); +} + +const submitPipelineRun = tool({ + name: "submit_pipeline_run", + description: + "Submit a pipeline spec to the Tangle backend for execution. Returns the created run details.", + parameters: z.object({ + pipelineSpec: z + .string() + .describe("JSON string of the pipeline spec to run"), + runName: z + .string() + .nullable() + .optional() + .describe("Optional human-readable name"), + }), + execute: async ({ pipelineSpec, runName }) => { + try { + const result = await tangleApi("/api/runs/", { + method: "POST", + body: JSON.stringify({ + pipeline_spec: JSON.parse(pipelineSpec), + run_name: runName ?? undefined, + }), + }); + return JSON.stringify({ success: true, run: result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ success: false, error: message }); + } + }, +}); + +const getRunStatus = tool({ + name: "get_run_status", + description: "Get the current status of a pipeline run.", + parameters: z.object({ + runId: z.string().describe("The run ID to check"), + }), + execute: async ({ runId }) => { + try { + const result = await tangleApi(`/api/runs/${runId}`); + return JSON.stringify({ success: true, run: result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ success: false, error: message }); + } + }, +}); + +const debugPipelineRun = tool({ + name: "debug_pipeline_run", + description: + "Fetch detailed run info including per-task statuses, outputs, and error logs for debugging.", + parameters: z.object({ + runId: z.string().describe("The run ID to debug"), + }), + execute: async ({ runId }) => { + try { + const [run, tasks] = await Promise.all([ + tangleApi(`/api/runs/${runId}`), + tangleApi(`/api/runs/${runId}/tasks`).catch(() => null), + ]); + return JSON.stringify({ success: true, run, tasks }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ success: false, error: message }); + } + }, +}); + +export const pipelineRunTools = [ + submitPipelineRun, + getRunStatus, + debugPipelineRun, +]; diff --git a/src/agent/tools/searchComponents.ts b/src/agent/tools/searchComponents.ts new file mode 100644 index 000000000..d44ce0b0a --- /dev/null +++ b/src/agent/tools/searchComponents.ts @@ -0,0 +1,107 @@ +/** + * Semantic component search — delegated to the Tangle backend. + * + * Replaces the in-browser MemoryVectorStore + OpenAIEmbeddings approach. + * The backend owns the vector index and embedding model, the worker just + * issues a query and surfaces results to the agent. + * + * `recordComponentReference` is still called for every hit so that any + * `[Name](component://id)` link the agent emits in its final answer can be + * resolved back to a chip on the main thread. + */ +import { tool } from "@openai/agents"; +import { z } from "zod"; + +import { config } from "../config"; +import type { AgentSession } from "../session"; +import { recordComponentReference } from "../session"; + +interface ComponentSearchResult { + id: string; + name: string; + description: string; + score: number; + inputs: Array<{ name: string; type?: string }>; + outputs: Array<{ name: string; type?: string }>; + yamlText: string; +} + +interface ComponentSearchResponse { + results: ComponentSearchResult[]; + message?: string; +} + +async function searchComponentsApi( + query: string, + topK: number, +): Promise { + const url = `${config.tangleApiUrl}/api/agent/search_components`; + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, topK }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Tangle API ${res.status}: ${body}`); + } + return (await res.json()) as ComponentSearchResponse; +} + +export function createSearchComponentsTool(session: AgentSession) { + return tool({ + name: "search_components", + description: + "Search the Tangle component registry by semantic meaning. Returns results with an `id` field — use it in `[Name](component://id)` links. Use before assembling any task to find the right component.", + parameters: z.object({ + query: z + .string() + .describe("Natural language description of the component's function"), + topK: z + .number() + .nullable() + .optional() + .describe("Number of results to return (default 5)"), + }), + execute: async ({ query, topK }) => { + try { + const response = await searchComponentsApi(query, topK ?? 5); + + if (response.results.length === 0) { + return JSON.stringify({ + results: [], + message: response.message ?? "No components found for that query.", + }); + } + + return JSON.stringify({ + results: response.results.map((result) => { + recordComponentReference(session, { + id: result.id, + name: result.name, + description: result.description, + yamlText: result.yamlText, + }); + + return { + id: result.id, + name: result.name, + description: result.description, + score: Math.round(result.score * 1000) / 1000, + inputs: result.inputs, + outputs: result.outputs, + yamlText: + result.yamlText.length > 2000 + ? result.yamlText.substring(0, 2000) + "\n... (truncated)" + : result.yamlText, + }; + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ results: [], error: message }); + } + }, + }); +} diff --git a/src/agent/tools/searchDocs.ts b/src/agent/tools/searchDocs.ts new file mode 100644 index 000000000..a5b57eff7 --- /dev/null +++ b/src/agent/tools/searchDocs.ts @@ -0,0 +1,92 @@ +/** + * Semantic docs search — delegated to the Tangle backend. + * + * Replaces the in-browser MemoryVectorStore + OpenAIEmbeddings approach. + * The backend owns the docs vector index, the worker just forwards the + * query and returns rendered results. + */ +import { tool } from "@openai/agents"; +import { z } from "zod"; + +import { config } from "../config"; + +interface DocSearchResult { + title: string; + sectionTitle: string; + content: string; + url: string; + score: number; +} + +interface DocSearchResponse { + results: DocSearchResult[]; + message?: string; +} + +async function searchDocsApi( + query: string, + topK: number, +): Promise { + const url = `${config.tangleApiUrl}/api/agent/search_docs`; + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, topK }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Tangle API ${res.status}: ${body}`); + } + return (await res.json()) as DocSearchResponse; +} + +export const searchDocsTool = tool({ + name: "search_docs", + description: + "Search Tangle documentation by semantic meaning. Returns relevant doc sections with URLs to tangleml.com/docs. " + + "Use for conceptual questions about Tangle. Each result includes a `url` and `citation` field — " + + "always include the documentation link in your response.", + parameters: z.object({ + query: z + .string() + .describe("Natural language question about Tangle concepts or features"), + topK: z + .number() + .nullable() + .optional() + .describe("Number of results to return (default 5)"), + }), + execute: async ({ query, topK }) => { + try { + const response = await searchDocsApi(query, topK ?? 5); + + if (response.results.length === 0) { + return JSON.stringify({ + results: [], + message: response.message ?? "No docs found for that query.", + }); + } + + return JSON.stringify({ + results: response.results.map((result) => ({ + title: result.title, + sectionTitle: result.sectionTitle, + content: + result.content.length > 1500 + ? result.content.substring(0, 1500) + "\n... (truncated)" + : result.content, + url: result.url, + citation: `[${result.title}](${result.url})`, + score: Math.round(result.score * 1000) / 1000, + })), + instruction: + "IMPORTANT: You MUST include the `url` from the top result(s) in your response as a markdown link. " + + "Use the `citation` field directly, e.g. 'Learn more: [Title](url)'.", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ results: [], error: message }); + } + }, +}); diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 000000000..6fbfeb5b6 --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,16 @@ +/** + * Shared types between the worker and the main thread. + */ + +export interface ComponentRefData { + id: string; + name: string; + description: string; + yamlText: string; +} + +export interface AgentResponse { + answer: string; + threadId: string; + componentReferences: Record; +} diff --git a/src/agent/worker.ts b/src/agent/worker.ts new file mode 100644 index 000000000..33dcb1380 --- /dev/null +++ b/src/agent/worker.ts @@ -0,0 +1,132 @@ +/** + * Web Worker entry point for the in-browser agent. + * + * The main thread spawns this worker, hands it a Comlink-proxied + * `ToolBridgeApi` (which mutates the live MobX spec) and a status + * callback, then calls `ask()` for each user turn. All LLM traffic + * stays inside this worker so heavy JSON parsing never blocks the UI. + * + * Worker-only resolution of `@openai/agents-core/_shims` to its + * browser variant is handled by the `worker.plugins` block in + * `vite.config.js`. + * + * The `globalThis.process` stub below covers an unguarded + * `process.env.X` read in `@openai/agents-core` v0.4.x + * (`runner/sessionPersistence.mjs` reads + * `process.env.OPENAI_AGENTS__DEBUG_SAVE_SESSION` without a + * `typeof process` guard) that would otherwise throw + * `ReferenceError: process is not defined` on every turn that + * persists session state. The stub deliberately omits `.on` / + * `.exit` so the SDK's `typeof process.on === 'function'` checks + * still skip the Node-only branches and we do not pretend to be + * Node. It is inlined here (rather than living in a separate + * polyfill file) so Rolldown's worker bundle does not tree-shake + * the side-effect import, and so it runs in both `vite build` and + * `vite serve` (dev) modes, which `worker.rolldownOptions.define` + * would not. + */ +const __workerGlobal = globalThis as { process?: { env?: unknown } }; +if (typeof __workerGlobal.process === "undefined") { + __workerGlobal.process = { env: {} }; +} +// Anchor: keep Rolldown from tree-shaking the assignment above. In +// `vite build` Vite statically replaces `process.env` with `{}`, so the +// rest of the bundle never reads `globalThis.process` and Rolldown +// would otherwise treat the write as dead. Throwing on a hasOwnProperty +// check forces the write to be observable. +if (!Object.prototype.hasOwnProperty.call(globalThis, "process")) { + throw new Error("Tangle agent worker: globalThis.process polyfill failed"); +} + +import * as Comlink from "comlink"; + +import { invokeDispatcher } from "./agents/tangleDispatcher"; +import { createSession, type RecentPipelineRun } from "./session"; +import { loadSkill } from "./skills/loader"; +import type { ToolBridgeApi } from "./toolBridgeApi"; +import type { AgentResponse } from "./types"; + +export interface AskParams { + message: string; + threadId?: string; + selectedEntityId?: string; + recentRuns?: RecentPipelineRun[]; +} + +export interface AgentWorkerApi { + ask(params: AskParams): Promise; + ping(): Promise<"pong">; +} + +let bridge: ToolBridgeApi | null = null; +let emitStatus: (status: { text: string }) => void = () => {}; + +function generateThreadId(): string { + return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +const api: AgentWorkerApi = { + async ping() { + return "pong"; + }, + + async ask({ message, threadId, selectedEntityId, recentRuns }) { + if (!bridge) { + throw new Error( + "Tool bridge has not been initialized — call init() before ask().", + ); + } + + const resolvedThreadId = threadId ?? generateThreadId(); + const session = createSession({ + threadId: resolvedThreadId, + bridge, + emitStatus, + recentRuns, + }); + + const result = await invokeDispatcher({ + message, + threadId: resolvedThreadId, + selectedEntityId, + session, + }); + + const componentReferences: AgentResponse["componentReferences"] = {}; + for (const [id, ref] of session.componentReferences) { + if (result.answer.includes(`component://${id}`)) { + componentReferences[id] = { name: ref.name, yamlText: ref.yamlText }; + } + } + + return { + answer: result.answer, + threadId: result.threadId, + componentReferences, + }; + }, +}; + +/** + * Initialization entry point. Called once by the main thread immediately + * after spawning the worker. Splits init from ask() so the bridge proxy + * lifecycle is explicit. Skill caches are warmed eagerly so the first + * agent turn does not pay the network cost. + * + * The bridge and onStatus arguments must be passed as separate top-level + * arguments so each can be marked with `Comlink.proxy()` and transferred + * via MessagePort. Comlink does NOT recursively walk object arguments + * looking for proxy markers — wrapping them in `{ bridge, onStatus }` + * here would cause structured-clone of the bridge methods and fail. + */ +export function init( + toolBridge: ToolBridgeApi, + onStatus: (status: { text: string }) => void, +): void { + bridge = toolBridge; + emitStatus = onStatus; + void loadSkill("tangleBestPractices").catch(() => {}); + void loadSkill("componentYamlFormat").catch(() => {}); +} + +Comlink.expose({ ...api, init }); diff --git a/src/routes/v2/pages/Editor/components/AiChat/AiChatContent.tsx b/src/routes/v2/pages/Editor/components/AiChat/AiChatContent.tsx index c607b4821..e9dbd1247 100644 --- a/src/routes/v2/pages/Editor/components/AiChat/AiChatContent.tsx +++ b/src/routes/v2/pages/Editor/components/AiChat/AiChatContent.tsx @@ -1,11 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { observer } from "mobx-react-lite"; +import type { RecentPipelineRun } from "@/agent/session"; import { BlockStack } from "@/components/ui/layout"; import useToastNotification from "@/hooks/useToastNotification"; +import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import type { PipelineRun } from "@/types/pipelineRun"; +import { isWorkerRuntimeEnabled } from "./aiChatStore"; import { useAiChatStore } from "./AiChatStoreContext"; import { ChatInput } from "./components/ChatInput"; import { ChatMessageList } from "./components/ChatMessageList"; @@ -14,8 +17,20 @@ import { useProcessCommands } from "./useProcessCommands"; const MAX_RECENT_RUNS = 5; +function toAgentRecentRuns(runs: PipelineRun[]): RecentPipelineRun[] { + return runs.map((r) => ({ + id: String(r.id), + root_execution_id: String(r.root_execution_id), + created_at: r.created_at, + created_by: r.created_by, + pipeline_name: r.pipeline_name, + status: r.status, + })); +} + export const AiChatContent = observer(function AiChatContent() { const { editor, navigation } = useSharedStores(); + const editorSession = useEditorSession(); const aiChat = useAiChatStore(); const { createSession } = useProcessCommands(); const notify = useToastNotification(); @@ -33,6 +48,17 @@ export const AiChatContent = observer(function AiChatContent() { ]) ?? []; const recentRuns = cachedRuns.slice(0, MAX_RECENT_RUNS); + if (isWorkerRuntimeEnabled()) { + aiChat.sendMessageViaWorker(prompt, { + selectedEntityId, + recentRuns: toAgentRecentRuns(recentRuns), + getSpec: () => navigation.rootSpec, + undo: editorSession.undo, + onError: (msg) => notify(msg, "error"), + }); + return; + } + const session = createSession(); aiChat.sendMessage(prompt, { currentSpec, diff --git a/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts b/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts new file mode 100644 index 000000000..e4d63a277 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts @@ -0,0 +1,92 @@ +/** + * Main-thread client for the in-browser agent worker. + * + * Spawns a single Web Worker (lazy, on first use), wires it up with a + * Comlink-proxied tool bridge that talks to the live MobX editor state, + * and exposes a typed `ask()` method that the AI Chat store calls. + */ +import * as Comlink from "comlink"; + +import type { RecentPipelineRun } from "@/agent/session"; +import type { ToolBridgeApi } from "@/agent/toolBridgeApi"; +import type { AgentResponse } from "@/agent/types"; +import type { AgentWorkerApi } from "@/agent/worker"; +import type { ComponentSpec } from "@/models/componentSpec"; +import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; + +import { createToolBridge } from "./toolBridge"; + +interface WorkerExports extends AgentWorkerApi { + init( + bridge: ToolBridgeApi, + onStatus: (status: { text: string }) => void, + ): void; +} + +interface InitDeps { + getSpec: () => ComponentSpec | null; + undo: UndoGroupable; + onStatus: (status: { text: string }) => void; +} + +interface AskOptions { + message: string; + threadId?: string; + selectedEntityId?: string; + recentRuns?: RecentPipelineRun[]; +} + +class AgentClient { + private worker: Worker | null = null; + private remote: Comlink.Remote | null = null; + private initPromise: Promise | null = null; + + private async ensureInit( + deps: InitDeps, + ): Promise> { + if (!this.worker) { + this.worker = new Worker(new URL("@/agent/worker.ts", import.meta.url), { + type: "module", + name: "tangle-agent", + }); + this.remote = Comlink.wrap(this.worker); + } + if (!this.remote) { + throw new Error("Worker remote was not created"); + } + if (!this.initPromise) { + const bridge = createToolBridge({ + getSpec: deps.getSpec, + undo: deps.undo, + }); + // Pass bridge and onStatus as separate top-level args: Comlink only + // applies its proxy transfer handler to top-level argument values, + // it does not recursively walk into properties of an object arg. + this.initPromise = this.remote.init( + Comlink.proxy(bridge), + Comlink.proxy(deps.onStatus), + ); + } + await this.initPromise; + return this.remote; + } + + async ask(deps: InitDeps, options: AskOptions): Promise { + const remote = await this.ensureInit(deps); + return remote.ask(options); + } + + terminate(): void { + this.worker?.terminate(); + this.worker = null; + this.remote = null; + this.initPromise = null; + } +} + +let singleton: AgentClient | null = null; + +export function getAgentClient(): AgentClient { + if (!singleton) singleton = new AgentClient(); + return singleton; +} diff --git a/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts b/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts index 1cd0e95c1..5952e7ac8 100644 --- a/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts +++ b/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts @@ -1,8 +1,12 @@ import { action, makeObservable, observable, runInAction } from "mobx"; +import type { RecentPipelineRun } from "@/agent/session"; +import type { ComponentSpec } from "@/models/componentSpec"; +import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; import type { PipelineRun } from "@/types/pipelineRun"; import { getErrorMessage } from "@/utils/string"; +import { getAgentClient } from "./agentClient"; import type { AiChatRequest, ChatMessage, @@ -52,6 +56,21 @@ interface SendMessageOptions { onError: (message: string) => void; } +interface SendMessageViaWorkerOptions { + selectedEntityId: string | null; + recentRuns: RecentPipelineRun[]; + getSpec: () => ComponentSpec | null; + undo: UndoGroupable; + onError: (message: string) => void; +} + +const WORKER_RUNTIME_FLAG = "agent.runtime"; + +export function isWorkerRuntimeEnabled(): boolean { + if (typeof localStorage === "undefined") return false; + return localStorage.getItem(WORKER_RUNTIME_FLAG) === "worker"; +} + /** * Stores AI chat state (messages, thread, streaming status) outside the * React component tree so it survives window minimize / hide / unmount. @@ -178,4 +197,74 @@ export class AiChatStore { }); } } + + /** + * Send a message using the in-browser agent worker. Tools mutate the + * live MobX spec directly via the tool bridge, so the assistant's + * changes are visible immediately and no command-replay step is + * needed. Mirrors `sendMessage` for thread, status, and message + * accounting. + */ + async sendMessageViaWorker( + prompt: string, + options: SendMessageViaWorkerOptions, + ) { + runInAction(() => { + this.messages = [ + ...this.messages, + { id: generateMessageId(), role: "user", content: prompt }, + ]; + this.isPending = true; + this.thinkingText = null; + }); + + try { + const client = getAgentClient(); + const response = await client.ask( + { + getSpec: options.getSpec, + undo: options.undo, + onStatus: (status) => { + runInAction(() => { + this.thinkingText = status.text; + }); + }, + }, + { + message: prompt, + ...(this.threadId && { threadId: this.threadId }), + ...(options.selectedEntityId && { + selectedEntityId: options.selectedEntityId, + }), + ...(options.recentRuns.length > 0 && { + recentRuns: options.recentRuns, + }), + }, + ); + + runInAction(() => { + this.thinkingText = null; + this.threadId = response.threadId; + const hasRefs = Object.keys(response.componentReferences).length > 0; + this.messages = [ + ...this.messages, + { + id: generateMessageId(), + role: "assistant", + content: response.answer, + ...(hasRefs && { + componentReferences: response.componentReferences, + }), + }, + ]; + }); + } catch (error) { + options.onError("AI request failed: " + getErrorMessage(error)); + } finally { + runInAction(() => { + this.isPending = false; + this.thinkingText = null; + }); + } + } } diff --git a/src/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi.ts b/src/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi.ts index 6ebec7b0c..6903a2807 100644 --- a/src/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi.ts +++ b/src/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi.ts @@ -5,7 +5,7 @@ import type { } from "@/models/componentSpec"; import { isGraphImplementation } from "@/utils/componentSpec"; -export interface AiInputSpec { +interface AiInputSpec { $id: string; name: string; type?: TypeSpecType; @@ -14,14 +14,14 @@ export interface AiInputSpec { optional?: boolean; } -export interface AiOutputSpec { +interface AiOutputSpec { $id: string; name: string; type?: TypeSpecType; description?: string; } -export interface AiComponentRef { +interface AiComponentRef { name?: string; url?: string; spec?: { @@ -31,7 +31,7 @@ export interface AiComponentRef { }; } -export interface AiTaskSpec { +interface AiTaskSpec { $id: string; name: string; componentRef: AiComponentRef; @@ -39,7 +39,7 @@ export interface AiTaskSpec { isSubgraph?: boolean; } -export interface AiBindingSpec { +interface AiBindingSpec { $id: string; sourceEntityId: string; sourcePortName: string; diff --git a/src/routes/v2/pages/Editor/components/AiChat/toolBridge.ts b/src/routes/v2/pages/Editor/components/AiChat/toolBridge.ts new file mode 100644 index 000000000..990a0fdef --- /dev/null +++ b/src/routes/v2/pages/Editor/components/AiChat/toolBridge.ts @@ -0,0 +1,255 @@ +/** + * Main-thread implementation of the agent's `ToolBridgeApi`. + * + * Each method mutates the LIVE MobX `ComponentSpec` from the editor session + * inside an undo group, so the agent's edits are user-visible immediately + * and undoable as a single step. The worker proxies these methods via + * Comlink — no command serialization or tempId remapping is needed. + */ +import type { + ConnectArgs, + ToolBridgeApi, + ValidationResult, +} from "@/agent/toolBridgeApi"; +import type { ComponentReference, ComponentSpec } from "@/models/componentSpec"; +import { validateSpec } from "@/models/componentSpec/validation/validateSpec"; +import { + connectNodes, + deleteSelectedEdgesByEdgeIds, +} from "@/routes/v2/pages/Editor/store/actions/connection.actions"; +import { + addInput, + addOutput, + deleteInput, + deleteOutput, + renameInput, + renameOutput, + setInputDefaultValue, + setInputDescription, + setInputType, + setOutputDescription, +} from "@/routes/v2/pages/Editor/store/actions/io.actions"; +import { + createSubgraph, + renamePipeline, + updatePipelineDescription, +} from "@/routes/v2/pages/Editor/store/actions/pipeline.actions"; +import { + addTask, + deleteTask, + renameTask, + unpackSubgraphTask, +} from "@/routes/v2/pages/Editor/store/actions/task.actions"; +import type { UndoGroupable } from "@/routes/v2/shared/nodes/types"; +import { hydrateComponentReference } from "@/services/componentService"; + +import { serializeSpecForAi } from "./serializeSpecForAi"; + +const DEFAULT_POSITION = { x: 250, y: 250 }; +const POSITION_OFFSET = 200; + +interface BridgeDeps { + getSpec: () => ComponentSpec | null; + undo: UndoGroupable; +} + +function computeNextPosition(spec: ComponentSpec): { x: number; y: number } { + const allEntities = [...spec.tasks, ...spec.inputs, ...spec.outputs]; + if (allEntities.length === 0) return DEFAULT_POSITION; + + let maxX = 0; + let maxY = 0; + for (const entity of allEntities) { + const pos = entity.annotations.get("editor.position") as + | { x: number; y: number } + | undefined; + if (pos) { + maxX = Math.max(maxX, pos.x); + maxY = Math.max(maxY, pos.y); + } + } + return { x: maxX + POSITION_OFFSET, y: maxY }; +} + +/** + * Builds the bridge implementation. Returned object is intended to be + * exposed to the worker via `Comlink.expose()`. Methods throw when the + * spec is unavailable (no pipeline open) so the worker surfaces a clear + * error to the model. + */ +export function createToolBridge(deps: BridgeDeps): ToolBridgeApi { + function requireSpec(): ComponentSpec { + const spec = deps.getSpec(); + if (!spec) { + throw new Error( + "No pipeline is currently open — open a pipeline before asking the agent to edit it.", + ); + } + return spec; + } + + return { + async getPipelineState() { + return serializeSpecForAi(requireSpec()); + }, + + async setPipelineName(name) { + const spec = requireSpec(); + renamePipeline(deps.undo, spec, name); + return { success: true }; + }, + + async setPipelineDescription(description) { + const spec = requireSpec(); + updatePipelineDescription(deps.undo, spec, description); + return { success: true }; + }, + + async addTask({ name, componentRef }) { + const spec = requireSpec(); + const hydrated = + (await hydrateComponentReference(componentRef as ComponentReference)) ?? + (componentRef as ComponentReference); + const position = computeNextPosition(spec); + const task = addTask(deps.undo, spec, hydrated, position); + if (name && task.name !== name) { + renameTask(deps.undo, spec, task.$id, name); + } + return { success: true, taskId: task.$id, name: task.name }; + }, + + async deleteTask(entityId) { + const spec = requireSpec(); + return { success: deleteTask(deps.undo, spec, entityId) }; + }, + + async renameTask(entityId, newName) { + const spec = requireSpec(); + return { success: renameTask(deps.undo, spec, entityId, newName) }; + }, + + async addInput({ name, type, description, defaultValue, optional }) { + const spec = requireSpec(); + const position = computeNextPosition(spec); + const input = addInput(deps.undo, spec, position, name); + if (type) setInputType(deps.undo, spec, input.$id, type); + if (description) + setInputDescription(deps.undo, spec, input.$id, description); + if (defaultValue) + setInputDefaultValue(deps.undo, spec, input.$id, defaultValue); + if (optional !== undefined) { + deps.undo.withGroup("Set input optional", () => { + input.setOptional(optional); + }); + } + return { success: true, inputId: input.$id, name: input.name }; + }, + + async deleteInput(entityId) { + const spec = requireSpec(); + return { success: deleteInput(deps.undo, spec, entityId) }; + }, + + async renameInput(entityId, newName) { + const spec = requireSpec(); + return { success: renameInput(deps.undo, spec, entityId, newName) }; + }, + + async addOutput({ name, type, description }) { + const spec = requireSpec(); + const position = computeNextPosition(spec); + const output = addOutput(deps.undo, spec, position, name); + if (type) { + deps.undo.withGroup("Set output type", () => output.setType(type)); + } + if (description) + setOutputDescription(deps.undo, spec, output.$id, description); + return { success: true, outputId: output.$id, name: output.name }; + }, + + async deleteOutput(entityId) { + const spec = requireSpec(); + return { success: deleteOutput(deps.undo, spec, entityId) }; + }, + + async renameOutput(entityId, newName) { + const spec = requireSpec(); + return { success: renameOutput(deps.undo, spec, entityId, newName) }; + }, + + async connectNodes(args: ConnectArgs) { + const spec = requireSpec(); + const ok = connectNodes(deps.undo, spec, { + sourceNodeId: args.sourceEntityId, + sourceHandleId: `output_${args.sourcePortName}`, + targetNodeId: args.targetEntityId, + targetHandleId: `input_${args.targetPortName}`, + }); + if (!ok) { + return { + success: false, + error: + "Could not create binding — invalid source/target combination.", + }; + } + const binding = spec.bindings.find( + (b) => + b.sourceEntityId === args.sourceEntityId && + b.sourcePortName === args.sourcePortName && + b.targetEntityId === args.targetEntityId && + b.targetPortName === args.targetPortName, + ); + return { success: true, bindingId: binding?.$id }; + }, + + async deleteEdge(entityId) { + const spec = requireSpec(); + deleteSelectedEdgesByEdgeIds(deps.undo, spec, [`edge_${entityId}`]); + return { success: true }; + }, + + async setTaskArgument(taskEntityId, inputName, value) { + const spec = requireSpec(); + deps.undo.withGroup("Set task argument", () => { + spec.setTaskArgument(taskEntityId, inputName, value); + }); + return { success: true }; + }, + + async createSubgraph(taskEntityIds, subgraphName) { + const spec = requireSpec(); + const position = computeNextPosition(spec); + const subgraphTask = createSubgraph( + deps.undo, + spec, + taskEntityIds, + subgraphName, + position, + ); + if (!subgraphTask) { + return { success: false, error: "Could not create subgraph" }; + } + return { success: true, subgraphTaskId: subgraphTask.$id }; + }, + + async unpackSubgraph(taskEntityId) { + const spec = requireSpec(); + return { success: unpackSubgraphTask(deps.undo, spec, taskEntityId) }; + }, + + async validatePipeline(): Promise { + const issues = validateSpec(requireSpec()); + return { + valid: issues.length === 0, + issueCount: issues.length, + issues: issues.map((i) => ({ + type: i.type, + severity: i.severity, + message: i.message, + entityId: i.entityId, + issueCode: i.issueCode, + })), + }; + }, + }; +} diff --git a/vite.config.js b/vite.config.js index 448d98e75..d1a5ead24 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,6 @@ import tailwindcss from "@tailwindcss/vite"; import viteReact from "@vitejs/plugin-react"; +import { createRequire } from "module"; import path from "path"; import { fileURLToPath } from "url"; import { defineConfig, loadEnv } from "vite"; @@ -11,6 +12,17 @@ import { REACT_COMPILER_ENABLED_DIRS } from "./react-compiler.config.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +// `@openai/agents-core` only exposes `.` and `./_shims` in its `exports` +// map — the raw `dist/shims/shims-browser.mjs` subpath is not exported, +// so `require.resolve` on it fails. Resolve the public root entry and +// walk to the sibling browser shim file inside the package. +const agentsCoreBrowserShim = path.resolve( + path.dirname(require.resolve("@openai/agents-core")), + "shims/shims-browser.mjs", +); + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); @@ -73,6 +85,37 @@ export default defineConfig(({ mode }) => { "@": path.resolve(__dirname, "./src"), }, }, + // The agent runs in a Web Worker. `@openai/agents-core/_shims` + // exposes a `browser` export condition, but Vite's worker bundle + // does not reliably apply it — the catch-all condition falls + // through to the Node shim that imports `node:process`. We force + // the browser variant via a scoped `resolveId`, kept here so the + // main bundle (which already resolves correctly via export + // conditions) is not affected. + // + // The runtime side of "no Node in the worker" — specifically the + // unguarded `process.env.X` read in `@openai/agents-core` — is + // handled by `src/agent/polyfills.ts`, not here, so the fix + // applies in both `vite build` and `vite serve` (dev) modes. + // + // `debug` (transitive of `@openai/agents-core`) is handled + // automatically by its package.json `browser` field, which Vite + // does honor for the worker bundle. + worker: { + format: "es", + plugins: () => [ + { + name: "tangle-agent-worker-shims", + enforce: "pre", + resolveId(id) { + if (id === "@openai/agents-core/_shims") { + return agentsCoreBrowserShim; + } + return null; + }, + }, + ], + }, assetsInclude: ["**/*.yaml", "**/*.py"], test: { globals: true,