Compare commits

..

58 Commits

Author SHA1 Message Date
Simon 3a2f0a41ec chore(version): bump version to 1.9.0 2026-06-08 22:42:46 +08:00
Simon 64bb1f1a56 Merge pull request #541 from alibaba/docs/update-model-list
docs: update model list
2026-06-08 22:37:23 +08:00
Simon 2afcf13fbb feat: update model list 2026-06-08 22:34:33 +08:00
Simon f617728fc3 Merge pull request #540 from alibaba/feat/claude-opus-4-8
feat(llms): add Claude Opus 4.8 support
2026-06-08 22:27:53 +08:00
Simon 9376abfe2a Merge pull request #539 from alibaba/docs/abort-and-concurrency
docs(website): document ctx.signal abort contract and execute() concurrency
2026-06-08 22:26:19 +08:00
Simon 2f343bcc56 feat(llms): add Claude Opus 4.8 support
Strip temperature for claude-opus-4-8, add tests, list model on website.
2026-06-08 22:23:56 +08:00
Simon 4108f67079 docs(website): document ctx.signal abort contract and execute() concurrency 2026-06-08 22:19:25 +08:00
Simon 0ba3bbd67f Merge pull request #536 from alibaba/fix/prevent-concurrent-execute
fix(core): prevent concurrent execute() calls
2026-06-08 22:04:19 +08:00
Simon 552987eb56 fix(core): prevent concurrent execute() calls 2026-06-08 21:58:16 +08:00
Simon 99ef08ac79 Merge pull request #532 from alibaba/feat/rubost-abort
feat: robust aborting
2026-06-08 21:53:05 +08:00
Simon 4445bec08a fix: abort may affect onAfterTask; clean up while loop 2026-06-08 19:46:19 +08:00
Simon 6530f0ef40 chore: cleanup; rm get abortSignal 2026-06-08 17:40:44 +08:00
Simon f8676a5cc2 fix: throw unhonored AbortError & rm detection code for it 2026-06-08 17:27:05 +08:00
Simon 029f64e4bc Merge branch 'feat/rubost-abort' of https://github.com/alibaba/page-agent into feat/rubost-abort 2026-06-05 21:25:07 +08:00
Simon 78b6e2ad3c feat: all sync tools should respect aborting 2026-06-05 21:23:18 +08:00
Simon 112f9fff39 docs: planning 2026-06-05 20:27:30 +08:00
Simon 1c82030a21 Merge pull request #531 from alibaba/chore/vitest-llms
chore(llms): add vitest unit tests
2026-06-04 21:05:19 +08:00
Simon f378972ef8 docs: fix test workflow docs 2026-06-04 20:43:09 +08:00
Simon 68ae73d4e6 chore(llms): add vitest unit tests for llms package 2026-06-04 20:27:33 +08:00
Simon 874b302860 Merge pull request #530 from alibaba/fix/dts
fix: dts bug hotifx
2026-06-04 17:51:00 +08:00
Simon 90ff96ca15 fix: dts global declarations should not be bundled 2026-06-04 17:44:59 +08:00
Simon 3c0d55fa33 Merge pull request #527 from alibaba/fix/abort-error-contract
improve error handling
2026-06-03 23:40:48 +08:00
Simon 9891e02215 fix(llms): handle AbortError during .json() 2026-06-03 23:31:01 +08:00
Simon 823195ad94 fix(llms): add error handling for invalid JSON responses and schema validation 2026-06-03 23:14:12 +08:00
Simon fd12fb9f1b refactor(llms): split AbortError out of InvokeError 2026-06-03 23:00:33 +08:00
Simon fb8de08c39 fix(llms): fix abort handling and clean up retry logic 2026-06-03 22:44:54 +08:00
Simon 738a62e96c Merge pull request #526 from alibaba/chore/typo
chore: typo
2026-06-03 20:22:17 +08:00
Simon e7b93efda0 chore: typo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:20:37 +08:00
Simon 0b6f0a6068 Merge pull request #524 from alibaba/dependabot/npm_and_yarn/development-major-a28a93d02a
chore(deps-dev): bump concurrently from 9.2.1 to 10.0.3 in the development-major group
2026-06-03 15:12:41 +08:00
Simon cc16d39e00 Merge pull request #525 from alibaba/fix/wxt-audit-overrides
fix: temporarily patch WXT audit vulnerabilities
2026-06-03 15:11:51 +08:00
Simon 7eceff26ef fix: patch WXT audit vulnerabilities 2026-06-03 15:05:34 +08:00
dependabot[bot] 61a791f759 chore(deps-dev): bump concurrently in the development-major group
Bumps the development-major group with 1 update: [concurrently](https://github.com/open-cli-tools/concurrently).


Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

---
updated-dependencies:
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 06:05:01 +00:00
Simon 6ecd6e61f6 Merge pull request #506 from tolerious/fix/fix-bug-505
fix(website): normalize trailing slash in sidebar location comparison
2026-06-02 17:56:30 +08:00
Simon 1e13385f1f Merge pull request #520 from alibaba/chore/upgrade-deps
chore: upgrade deps
2026-06-02 17:16:54 +08:00
Simon dec9fe8b3d chore: update node version and lockfile 2026-06-02 17:11:18 +08:00
Simon ebd8ec5ae7 chore: upgrade deps 2026-05-28 16:06:15 +08:00
Toly 482190777c fix(website): normalize trailing slash in sidebar location comparison 2026-05-16 21:03:51 +08:00
Simon ebc785d1fe chore(version): bump version to 1.8.2 2026-05-11 19:40:30 +08:00
Simon ba932283f2 Merge pull request #501 from alibaba/feat/iife-cdn-without-auto-init
feat: add autoInit switch for demo CDN script
2026-05-11 19:39:19 +08:00
Simon f05bf68f61 docs: ?autoInit=false docs 2026-05-11 19:28:49 +08:00
Simon 35ab12a20a feat: add autoInit switch for IIFE demo 2026-05-11 19:20:52 +08:00
Simon fd7c54a695 Merge pull request #500 from alibaba/fix/demo-showpanel-type
fix(page-agent): keep demo showPanel out of config type
2026-05-11 19:06:35 +08:00
Simon eafd834daf fix(page-agent): keep demo showPanel out of config type 2026-05-11 19:05:27 +08:00
Feffery f575ae0fa4 feat: add showPanel switch to IIFE demo 2026-05-11 19:01:00 +08:00
Simon 797c9f8575 Merge pull request #499 from alibaba/chore/upgrade-dependencies
chore: upgrade dependencies
2026-05-11 17:23:36 +08:00
Simon fb3ac4d454 chore: upgrade dependencies 2026-05-11 17:19:23 +08:00
Simon c629d706ec chore: remove vite-bundle-analyzer 2026-04-28 16:46:05 +08:00
Simon 7896786311 Merge pull request #491 from alibaba/feat/vite8
Upgrade vite 8
2026-04-28 16:29:42 +08:00
Simon 820a2654ff fix: set minimal npm version to avoid peer: true 2026-04-28 16:24:50 +08:00
Simon 8d45ab8436 chore(deps): update package-lock.json 2026-04-28 16:15:16 +08:00
Simon 855020636b Merge branch 'main' into feat/vite8 2026-04-28 16:03:01 +08:00
Simon ff82603839 Merge pull request #490 from alibaba/fix/ts-value-space
fix(ts): `InvokeErrorType` separate confusing value/type space
2026-04-28 16:02:35 +08:00
Simon 90270fb76c fix(ts): InvokeErrorType separate confusing value/type space 2026-04-28 15:57:51 +08:00
Simon c9edeaea44 refactor(setup): upgrade vite8 2026-04-28 15:33:11 +08:00
Simon 757fe07b4d Merge pull request #489 from alibaba/fix/website-manual-chunks
fix(website): make manualChunks work
2026-04-28 14:44:29 +08:00
Simon f04fa706f7 fix(website): make manualChunks work 2026-04-28 14:42:34 +08:00
Simon 9daf914a89 Merge pull request #486 from alibaba/dependabot/npm_and_yarn/development-dependencies-82874c61ad
chore(deps-dev): bump the development-dependencies group with 7 updates
2026-04-28 14:21:07 +08:00
dependabot[bot] c8e5ff3010 chore(deps-dev): bump the development-dependencies group with 7 updates
Bumps the development-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.5.0` | `20.5.2` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.58.6` | `7.58.7` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.3` | `4.2.4` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.8.0` | `1.11.0` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.17.0` | `16.18.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.3` | `4.2.4` |


Updates `@commitlint/cli` from 20.5.0 to 20.5.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.5.2/@commitlint/cli)

Updates `@microsoft/api-extractor` from 7.58.6 to 7.58.7
- [Changelog](https://github.com/microsoft/rushstack/blob/main/apps/api-extractor/CHANGELOG.md)
- [Commits](https://github.com/microsoft/rushstack/commits/@microsoft/api-extractor_v7.58.7/apps/api-extractor)

Updates `@tailwindcss/vite` from 4.2.3 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

Updates `lucide-react` from 1.8.0 to 1.11.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.11.0/packages/lucide-react)

Updates `simple-icons` from 16.17.0 to 16.18.0
- [Release notes](https://github.com/simple-icons/simple-icons/releases)
- [Commits](https://github.com/simple-icons/simple-icons/compare/16.17.0...16.18.0)

Updates `tailwindcss` from 4.2.3 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.58.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 22:52:43 +00:00
43 changed files with 2665 additions and 3169 deletions
+15
View File
@@ -23,6 +23,7 @@ npm run build # Build all packages
npm run build:libs # Build all libraries
npm run build:ext # Build and zip the extension package
npm run typecheck # Typecheck all packages
npm run test # Run unit tests across all workspaces
npm run lint # ESLint
```
@@ -125,6 +126,20 @@ const pageInfo = await this.pageController.getPageInfo()
2. Expose via async method in `PageController.ts`
3. Export from `packages/page-controller/src/index.ts`
## Testing
- **Framework**: Vitest (unit tests only for now; future E2E goes to `packages/e2e/` with Playwright)
- **Location**: co-located, `src/foo.test.ts` next to `src/foo.ts`
- **Coverage today**: `packages/llms` only — other packages will follow incrementally
- **Adding tests to a new package**: create `vitest.config.ts` in the package and add a `"test": "vitest run"` script. Root `npm test` and `node scripts/ci.js` pick it up through npm workspaces.
- **Template**: See @page-agent/llms
```bash
npm test # all packages with a test script
npm test -w @page-agent/llms # single package
cd packages/llms && npx vitest # watch mode in one package
```
## Code Standards
- Explicit typing for exported/public APIs
+4 -2
View File
@@ -50,8 +50,10 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
### NPM Installation
+4 -2
View File
@@ -49,8 +49,10 @@
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
### NPM 安装
+1606 -2860
View File
File diff suppressed because it is too large Load Diff
+29 -22
View File
@@ -1,7 +1,7 @@
{
"name": "root",
"private": true,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"workspaces": [
"packages/page-controller",
@@ -22,7 +22,8 @@
},
"homepage": "https://alibaba.github.io/page-agent/",
"engines": {
"node": "^22.13.0 || >=24"
"node": "^22.22.1 || >=24",
"npm": "^11.6.3"
},
"scripts": {
"start": "npm run dev --workspace=@page-agent/website",
@@ -35,39 +36,45 @@
"version": "node scripts/sync-version.js",
"postpublish": "npm run postpublish --workspaces --if-present",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
"test": "npm test --workspaces --if-present",
"lint": "eslint .",
"ci": "node scripts/ci.js",
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
"prepare": "husky || true"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint-react/eslint-plugin": "^4.2.3",
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@eslint-react/eslint-plugin": "^5.8.6",
"@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.58.6",
"@tailwindcss/vite": "^4.2.3",
"@microsoft/api-extractor": "^7.58.7",
"@tailwindcss/vite": "^4.3.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0",
"@vitejs/plugin-react-swc": "^4.3.0",
"@types/node": "^25.9.1",
"@vitejs/plugin-react": "^6.0.2",
"chalk": "^5.6.2",
"concurrently": "^9.2.1",
"concurrently": "^10.0.3",
"dotenv": "^17.4.2",
"eslint": "^10.2.0",
"globals": "^17.5.0",
"eslint": "^10.4.0",
"globals": "^17.6.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.59.0",
"unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.2",
"vite-bundle-analyzer": "^1.3.7",
"vite-plugin-css-injected-by-js": "^4.0.1"
"lint-staged": "^17.0.5",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"unplugin-dts": "^1.0.1",
"vite": "^8.0.14",
"vite-plugin-css-injected-by-js": "^5.0.1",
"vitest": "^4.1.8"
},
"overrides": {
"typescript": "^6.0.2",
"@vitejs/plugin-react": "^5.2.0"
"typescript": "^6.0.3",
"node-notifier": {
"uuid": "11.1.1"
},
"web-ext-run": {
"tmp": "0.2.7"
}
},
"lint-staged": {
"*.{js,ts,cjs,cts,mjs,mts}": [
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/core",
"private": false,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "./src/PageAgentCore.ts",
"types": "./src/PageAgentCore.ts",
@@ -54,13 +54,13 @@
},
"dependencies": {
"chalk": "^5.6.2",
"@page-agent/llms": "1.8.1",
"@page-agent/page-controller": "1.8.1"
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.6"
"zod": "^4.4.3"
}
}
+65 -79
View File
@@ -73,14 +73,21 @@ export class PageAgentCore extends EventTarget {
disposed = false
/**
* Callback for when agent needs user input (ask_user tool)
* If not set, ask_user tool will be disabled
* Called when the agent needs to ask the user questions.
* If unset, the `ask_user` tool will be disabled.
* Implementations should reject the promise when `signal` aborts.
* @example onAskUser: (q) => window.prompt(q) || ''
*/
onAskUser?: (question: string) => Promise<string>
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
#status: AgentStatus = 'idle'
#llm: LLM
/**
* Task cancellation primitive: its signal reaches the LLM fetch, tools
* (via `ctx.signal`) and async callbacks. Aborted only by `stop`/`dispose`
* (during a task) or task setup, always WITHOUT a reason so `signal.reason`
* stays a standard `AbortError`.
*/
#abortController = new AbortController()
#observations: string[] = []
@@ -103,11 +110,14 @@ export class PageAgentCore extends EventTarget {
this.tools = new Map(tools)
this.pageController = config.pageController
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => {
const { attempt, maxAttempts } = (e as CustomEvent).detail
const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering
this.history.push({
type: 'error',
message: String(lastError),
rawResponse: (lastError as InvokeError).rawResponse,
})
this.history.push({
type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
@@ -116,19 +126,6 @@ export class PageAgentCore extends EventTarget {
})
this.#emitHistoryChange()
})
this.#llm.addEventListener('error', (e) => {
const error = (e as CustomEvent).detail.error as Error | InvokeError
if ((error as any)?.rawError?.name === 'AbortError') return
const message = String(error)
this.#emitActivity({ type: 'error', message })
// Also push to history for panel rendering
this.history.push({
type: 'error',
message,
rawResponse: (error as InvokeError).rawResponse,
})
this.#emitHistoryChange()
})
if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) {
@@ -195,10 +192,20 @@ export class PageAgentCore extends EventTarget {
async execute(task: string): Promise<ExecutionResult> {
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
if (this.#status === 'running') throw new Error('A task is already running.')
if (!task) throw new Error('Task is required')
this.task = task
this.taskId = uid()
this.history = []
this.#observations = []
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
this.#abortController = new AbortController()
this.#setStatus('running')
this.#emitHistoryChange()
// Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) {
this.tools.delete('ask_user')
@@ -209,25 +216,17 @@ export class PageAgentCore extends EventTarget {
const onBeforeTask = this.config.onBeforeTask
const onAfterTask = this.config.onAfterTask
await onBeforeTask?.(this)
// Show mask
await this.pageController.showMask()
if (this.#abortController) {
this.#abortController.abort()
this.#abortController = new AbortController()
try {
await onBeforeTask?.(this)
await this.pageController.showMask()
} catch (error) {
this.#setStatus('error')
throw error
}
this.history = []
this.#setStatus('running')
this.#emitHistoryChange()
this.#observations = []
// Reset internal states
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
let step = 0
let taskSuccess: boolean
let taskResult: string
while (true) {
try {
@@ -289,63 +288,49 @@ export class PageAgentCore extends EventTarget {
} as AgentStepEvent)
this.#emitHistoryChange()
//
await onAfterStep?.(this, this.history)
console.groupEnd()
// finish task if done
if (actionName === 'done') {
const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), success, text)
this.#onDone(success)
const result: ExecutionResult = {
success,
data: text,
history: this.history,
}
await onAfterTask?.(this, result)
return result
taskSuccess = action.input?.success ?? false
taskResult = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult)
break
}
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
console.error('Task failed', error)
const errorMessage = isAbortError ? 'Task stopped' : String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
console.groupEnd()
const isAbortError = (error as any)?.name === 'AbortError'
if (!isAbortError) console.error('Task failed', error)
taskResult = isAbortError ? 'Task aborted' : String(error)
taskSuccess = false
this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: taskResult, rawResponse: error })
this.#emitHistoryChange()
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
break
}
step++
if (step > this.config.maxSteps) {
const errorMessage = 'Step count exceeded maximum limit'
this.history.push({ type: 'error', message: errorMessage })
taskResult = 'Step count exceeded maximum limit'
taskSuccess = false
this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: taskResult })
this.#emitHistoryChange()
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
break
}
await waitFor(this.config.stepDelay ?? 0.4)
}
this.#onDone(taskSuccess)
const result: ExecutionResult = {
success: taskSuccess,
data: taskResult,
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
/**
@@ -378,8 +363,8 @@ export class PageAgentCore extends EventTarget {
description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort
if (this.#abortController.signal.aborted) throw new Error('AbortError')
const signal = this.#abortController.signal
signal.throwIfAborted()
console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action
@@ -411,8 +396,9 @@ export class PageAgentCore extends EventTarget {
const startTime = Date.now()
// Execute tool, bind `this` to PageAgent
const result = await tool.execute.bind(this)(toolInput)
const result = await tool.execute.bind(this)(toolInput, { signal })
// Enforce abort even if the tool ignored the signal and resolved normally.
signal.throwIfAborted()
const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
+13 -5
View File
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils'
/**
* Per-invocation context passed to every tool execution.
* Tools MUST honor `signal` to support cooperative cancellation.
*/
export interface ToolContext {
signal: AbortSignal
}
/**
* Internal tool definition that has access to PageAgent `this` context
*/
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
// name: string
description: string
inputSchema: z.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string>
execute: (this: PageAgentCore, args: TParams, ctx: ToolContext) => Promise<string>
}
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
@@ -50,12 +58,12 @@ tools.set(
inputSchema: z.object({
seconds: z.number().min(1).max(10).default(1),
}),
execute: async function (this: PageAgentCore, input) {
execute: async function (this: PageAgentCore, input, { signal }) {
// try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime)
await waitFor(actualWaitTime, signal)
return `✅ Waited for ${input.seconds} seconds.`
},
@@ -70,11 +78,11 @@ tools.set(
inputSchema: z.object({
question: z.string(),
}),
execute: async function (this: PageAgentCore, input) {
execute: async function (this: PageAgentCore, input, { signal }) {
if (!this.onAskUser) {
throw new Error('ask_user tool requires onAskUser callback to be set')
}
const answer = await this.onAskUser(input.question)
const answer = await this.onAskUser(input.question, { signal })
return `User answered: ${answer}`
},
})
+3 -3
View File
@@ -1,4 +1,4 @@
import { InvokeError, InvokeErrorType } from '@page-agent/llms'
import { InvokeError, InvokeErrorTypes } from '@page-agent/llms'
import chalk from 'chalk'
import * as z from 'zod/v4'
@@ -137,7 +137,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
if (!tool) {
const available = Array.from(tools.keys()).join(', ')
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorTypes.INVALID_TOOL_ARGS,
`Unknown action "${toolName}". Available: ${available}`
)
}
@@ -159,7 +159,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
const result = schema.safeParse(value)
if (!result.success) {
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorTypes.INVALID_TOOL_ARGS,
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
)
}
+22 -2
View File
@@ -2,8 +2,28 @@ import chalk from 'chalk'
export * from './autoFixer'
export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
/**
* Wait for `seconds`. If a `signal` is provided, the wait is cancellable:
* aborting rejects with the signal's reason (an `AbortError`).
*/
export async function waitFor(seconds: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
return
}
signal.throwIfAborted()
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, seconds * 1000)
const onAbort = () => {
clearTimeout(timer)
// reason is a DOMException AbortError.
reject(signal.reason as DOMException)
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
//
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false,
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true,
compilerOptions: {
composite: true,
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/ext",
"private": true,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "wxt",
@@ -16,32 +16,32 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/vite": "^4.2.3",
"@types/chrome": "^0.1.40",
"@types/react": "^19.2.14",
"@tailwindcss/vite": "^4.3.0",
"@types/chrome": "^0.1.42",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"lucide-react": "^1.16.0",
"motion": "^12.40.0",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"rough-notation": "^0.5.1",
"simple-icons": "^16.16.0",
"simple-icons": "^16.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.25"
"wxt": "^0.20.26"
},
"dependencies": {
"@page-agent/core": "1.8.1",
"@page-agent/llms": "1.8.1",
"@page-agent/page-controller": "1.8.1",
"@page-agent/ui": "1.8.1",
"@page-agent/core": "1.9.0",
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.9.0",
"ai-motion": "^0.4.8",
"chalk": "^5.6.2"
},
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/llms",
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
@@ -43,6 +43,7 @@
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js"
},
@@ -53,6 +54,6 @@
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.6"
"zod": "^4.4.3"
}
}
+369
View File
@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { OpenAIClient } from './OpenAIClient'
import { InvokeError, InvokeErrorTypes } from './errors'
import { parseLLMConfig } from './index'
import type { LLMConfig, Tool } from './types'
// ---------- Fixtures ----------
function makeClient(overrides: Partial<LLMConfig> = {}) {
const fetchMock = vi.fn<typeof fetch>()
const config = parseLLMConfig({
baseURL: 'http://test.local/v1',
model: 'test-model',
apiKey: 'sk-test',
customFetch: fetchMock,
...overrides,
})
const client = new OpenAIClient(config)
return { client, fetchMock }
}
function makeTool(): Tool<{ name: string }, string> {
return {
description: 'greet',
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async (args) => `hello ${args.name}`),
}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function toolCallBody(toolName: string, args: unknown, finishReason = 'tool_calls') {
return {
choices: [
{
finish_reason: finishReason,
message: {
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: toolName,
arguments: typeof args === 'string' ? args : JSON.stringify(args),
},
},
],
},
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
function abortError(message = 'aborted'): Error {
const err = new Error(message)
err.name = 'AbortError'
return err
}
function getSentBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
const init = fetchMock.mock.calls[0][1] as RequestInit
return JSON.parse(init.body as string)
}
const signal = new AbortController().signal
// ---------- Request construction ----------
describe('OpenAIClient.invoke — request construction', () => {
let setup: ReturnType<typeof makeClient>
const tools = { greet: makeTool() }
beforeEach(() => {
setup = makeClient()
setup.fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
})
it('defaults tool_choice to "required" when no toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal)
expect(getSentBody(setup.fetchMock).tool_choice).toBe('required')
})
it('uses named tool_choice when toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(setup.fetchMock).tool_choice).toEqual({
type: 'function',
function: { name: 'greet' },
})
})
it('falls back to "required" when disableNamedToolChoice is true', async () => {
const { client, fetchMock } = makeClient({ disableNamedToolChoice: true })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(fetchMock).tool_choice).toBe('required')
})
it('sends Authorization header when apiKey is set', async () => {
await setup.client.invoke([], tools, signal)
const init = setup.fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test')
})
it('omits Authorization header when apiKey is empty', async () => {
const { client, fetchMock } = makeClient({ apiKey: '' })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
const init = fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBeUndefined()
})
it('applies transformRequestBody (in-place form, returns undefined)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: (body) => {
body.custom_flag = 42
return undefined
},
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock).custom_flag).toBe(42)
})
it('applies transformRequestBody (returning a new object)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: () => ({ replaced: true }),
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock)).toEqual({ replaced: true })
})
it('wraps transformRequestBody throws as CONFIG_ERROR', async () => {
const { client } = makeClient({
transformRequestBody: () => {
throw new Error('bad transform')
},
})
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.CONFIG_ERROR,
})
})
})
// ---------- Success path ----------
describe('OpenAIClient.invoke — success', () => {
it('returns toolCall, toolResult and usage', async () => {
const { client, fetchMock } = makeClient()
const tool = makeTool()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Alice' })))
const result = await client.invoke([], { greet: tool }, signal)
expect(result.toolCall).toEqual({ name: 'greet', args: { name: 'Alice' } })
expect(result.toolResult).toBe('hello Alice')
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cachedTokens: undefined,
reasoningTokens: undefined,
})
expect(tool.execute).toHaveBeenCalledWith({ name: 'Alice' })
})
it('accepts finish_reason="stop" with tool calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Bob' }, 'stop')))
const result = await client.invoke([], { greet: makeTool() }, signal)
expect(result.toolResult).toBe('hello Bob')
})
})
// ---------- HTTP errors ----------
describe('OpenAIClient.invoke — HTTP errors', () => {
const tools = { greet: makeTool() }
it.each([
[401, InvokeErrorTypes.AUTH_ERROR],
[403, InvokeErrorTypes.AUTH_ERROR],
[429, InvokeErrorTypes.RATE_LIMIT],
[500, InvokeErrorTypes.SERVER_ERROR],
[502, InvokeErrorTypes.SERVER_ERROR],
[418, InvokeErrorTypes.UNKNOWN],
])('maps status %i to %s', async (status, type) => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({ error: { message: 'nope' } }, status))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type,
})
})
it('falls back to statusText when body has no error.message', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
new Response('not json', { status: 500, statusText: 'Internal Boom' })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.SERVER_ERROR,
message: expect.stringContaining('Internal Boom'),
})
})
})
// ---------- Response anomalies ----------
describe('OpenAIClient.invoke — response anomalies', () => {
const tools = { greet: makeTool() }
it('throws CONTEXT_LENGTH on finish_reason="length"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'length', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTEXT_LENGTH,
})
})
it('throws CONTENT_FILTER on finish_reason="content_filter"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'content_filter', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTENT_FILTER,
})
})
it('throws INVALID_SCHEMA when there are no choices', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({}))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_SCHEMA,
})
})
it('throws NO_TOOL_CALL when message has no tool_calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'tool_calls', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.NO_TOOL_CALL,
})
})
it('throws INVALID_TOOL_ARGS when arguments are not valid JSON', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', 'not-json{')))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
message: expect.stringContaining('JSON'),
})
})
it('throws INVALID_TOOL_ARGS when args fail Zod validation', async () => {
const { client, fetchMock } = makeClient()
// tool expects { name: string }, send { name: 123 }
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 123 })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
})
})
it('throws UNKNOWN when model calls a tool that does not exist', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('mystery', { name: 'x' })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.UNKNOWN,
message: expect.stringContaining('mystery'),
})
})
it('wraps tool.execute failures as TOOL_EXECUTION_ERROR', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw new Error('downstream blew up')
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
await expect(client.invoke([], { greet: tool }, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.TOOL_EXECUTION_ERROR,
message: expect.stringContaining('downstream blew up'),
})
})
})
// ---------- Abort handling (the important part) ----------
describe('OpenAIClient.invoke — abort', () => {
const tools = { greet: makeTool() }
it('throws AbortError immediately when signal is already aborted', async () => {
const { client, fetchMock } = makeClient()
const controller = new AbortController()
controller.abort()
await expect(client.invoke([], tools, controller.signal)).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('propagates AbortError thrown by fetch (does NOT wrap as NETWORK_ERROR)', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(abortError())
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).toBeInstanceOf(Error)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by response.json() (does NOT wrap as INVALID_RESPONSE)', async () => {
const { client, fetchMock } = makeClient()
// Fake Response whose .json() rejects with AbortError mid-read
const fakeResponse = {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.reject(abortError()),
} as unknown as Response
fetchMock.mockResolvedValue(fakeResponse)
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by tool.execute (does NOT wrap as TOOL_EXECUTION_ERROR)', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw abortError()
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
const err = await client.invoke([], { greet: tool }, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
// Sanity check: the wrappers we deliberately bypass for AbortError still work for normal errors
it('still wraps non-Abort fetch errors as NETWORK_ERROR', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(new TypeError('ECONNREFUSED'))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.NETWORK_ERROR,
})
})
})
+43 -27
View File
@@ -3,7 +3,7 @@
*/
import * as z from 'zod/v4'
import { InvokeError, InvokeErrorType } from './errors'
import { InvokeError, InvokeErrorTypes } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
import { modelPatch, zodToOpenAITool } from './utils'
@@ -25,6 +25,8 @@ export class OpenAIClient implements LLMClient {
abortSignal?: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> {
abortSignal?.throwIfAborted()
// 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
@@ -50,7 +52,7 @@ export class OpenAIClient implements LLMClient {
transformedBody = this.config.transformRequestBody(requestBody)
} catch (error) {
throw new InvokeError(
InvokeErrorType.CONFIG_ERROR,
InvokeErrorTypes.CONFIG_ERROR,
`transformRequestBody failed: ${(error as Error).message}`,
error
)
@@ -70,52 +72,65 @@ export class OpenAIClient implements LLMClient {
signal: abortSignal,
})
} catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError'
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
if ((error as any)?.name === 'AbortError') throw error
console.error(error)
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
}
// 3. Handle HTTP errors
if (!response.ok) {
const errorData = await response.json().catch()
const errorMessage =
(errorData as { error?: { message?: string } }).error?.message || response.statusText
let errorData: any
try {
errorData = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
}
const errorMessage = errorData?.error?.message || response.statusText
if (response.status === 401 || response.status === 403) {
throw new InvokeError(
InvokeErrorType.AUTH_ERROR,
InvokeErrorTypes.AUTH_ERROR,
`Authentication failed: ${errorMessage}`,
errorData
)
}
if (response.status === 429) {
throw new InvokeError(
InvokeErrorType.RATE_LIMIT,
InvokeErrorTypes.RATE_LIMIT,
`Rate limit exceeded: ${errorMessage}`,
errorData
)
}
if (response.status >= 500) {
throw new InvokeError(
InvokeErrorType.SERVER_ERROR,
InvokeErrorTypes.SERVER_ERROR,
`Server error: ${errorMessage}`,
errorData
)
}
throw new InvokeError(
InvokeErrorType.UNKNOWN,
InvokeErrorTypes.UNKNOWN,
`HTTP ${response.status}: ${errorMessage}`,
errorData
)
}
// 4. Parse and validate response
const data = await response.json()
let data: any
try {
data = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError(
InvokeErrorTypes.INVALID_RESPONSE,
'Response body is not valid JSON',
error
)
}
const choice = data.choices?.[0]
if (!choice) {
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
}
// Check finish_reason
@@ -126,21 +141,21 @@ export class OpenAIClient implements LLMClient {
break
case 'length':
throw new InvokeError(
InvokeErrorType.CONTEXT_LENGTH,
InvokeErrorTypes.CONTEXT_LENGTH,
'Response truncated: max tokens reached',
undefined,
data
)
case 'content_filter':
throw new InvokeError(
InvokeErrorType.CONTENT_FILTER,
InvokeErrorTypes.CONTENT_FILTER,
'Content filtered by safety system',
undefined,
data
)
default:
throw new InvokeError(
InvokeErrorType.UNKNOWN,
InvokeErrorTypes.INVALID_SCHEMA,
`Unexpected finish_reason: ${choice.finish_reason}`,
undefined,
data
@@ -155,7 +170,7 @@ export class OpenAIClient implements LLMClient {
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
if (!toolCallName) {
throw new InvokeError(
InvokeErrorType.NO_TOOL_CALL,
InvokeErrorTypes.NO_TOOL_CALL,
'No tool call found in response',
undefined,
data
@@ -165,7 +180,7 @@ export class OpenAIClient implements LLMClient {
const tool = tools[toolCallName]
if (!tool) {
throw new InvokeError(
InvokeErrorType.UNKNOWN,
InvokeErrorTypes.UNKNOWN,
`Tool "${toolCallName}" not found in tools`,
undefined,
data
@@ -176,7 +191,7 @@ export class OpenAIClient implements LLMClient {
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
if (!argString) {
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorTypes.INVALID_TOOL_ARGS,
'No tool call arguments found',
undefined,
data
@@ -188,7 +203,7 @@ export class OpenAIClient implements LLMClient {
parsedArgs = JSON.parse(argString)
} catch (error) {
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorTypes.INVALID_TOOL_ARGS,
'Failed to parse tool arguments as JSON',
error,
data
@@ -200,7 +215,7 @@ export class OpenAIClient implements LLMClient {
if (!validation.success) {
console.error(z.prettifyError(validation.error))
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorTypes.INVALID_TOOL_ARGS,
'Tool arguments validation failed',
validation.error,
data
@@ -212,11 +227,12 @@ export class OpenAIClient implements LLMClient {
let toolResult: unknown
try {
toolResult = await tool.execute(toolInput)
} catch (e) {
} catch (error: unknown) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError(
InvokeErrorType.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`,
e,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(error as Error)?.message}`,
error,
data
)
}
+18 -20
View File
@@ -1,8 +1,8 @@
/**
* Error types and error handling for LLM invocations
* Error types and error handling for LLM invocations.
*/
export const InvokeErrorType = {
export const InvokeErrorTypes = {
// Retryable
NETWORK_ERROR: 'network_error', // Network error, retry
RATE_LIMIT: 'rate_limit', // Rate limit, retry
@@ -10,6 +10,8 @@ export const InvokeErrorType = {
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
INVALID_RESPONSE: 'invalid_response', // Response body is not valid JSON
INVALID_SCHEMA: 'invalid_schema', // Response is valid JSON but doesn't match expected shape
UNKNOWN: 'unknown',
@@ -20,7 +22,19 @@ export const InvokeErrorType = {
CONTENT_FILTER: 'content_filter', // Content filtered
} as const
export type InvokeErrorType = (typeof InvokeErrorType)[keyof typeof InvokeErrorType]
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.INVALID_RESPONSE,
InvokeErrorTypes.INVALID_SCHEMA,
InvokeErrorTypes.UNKNOWN,
]
export class InvokeError extends Error {
type: InvokeErrorType
@@ -35,24 +49,8 @@ export class InvokeError extends Error {
super(message)
this.name = 'InvokeError'
this.type = type
this.retryable = this.isRetryable(type, rawError)
this.retryable = RETRYABLE_TYPES.includes(type)
this.rawError = rawError
this.rawResponse = rawResponse
}
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [
InvokeErrorType.NETWORK_ERROR,
InvokeErrorType.RATE_LIMIT,
InvokeErrorType.SERVER_ERROR,
InvokeErrorType.NO_TOOL_CALL,
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorType.TOOL_EXECUTION_ERROR,
InvokeErrorType.UNKNOWN,
]
return retryableTypes.includes(type)
}
}
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InvokeError, InvokeErrorTypes, LLM } from './index'
import type { LLMClient } from './types'
function makeLLM(maxRetries = 2): LLM {
return new LLM({
baseURL: 'http://test.local/v1',
model: 'gpt-5',
maxRetries,
})
}
function abortError(): Error {
const err = new Error('aborted')
err.name = 'AbortError'
return err
}
describe('LLM.invoke retry behavior', () => {
let llm: LLM
let client: { invoke: ReturnType<typeof vi.fn> }
const signal = new AbortController().signal
beforeEach(() => {
llm = makeLLM(2)
client = { invoke: vi.fn() }
llm.client = client as unknown as LLMClient
})
it('returns immediately on first success', async () => {
client.invoke.mockResolvedValueOnce('ok')
const retryListener = vi.fn()
llm.addEventListener('retry', retryListener)
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledOnce()
expect(retryListener).not.toHaveBeenCalled()
})
it('retries up to maxRetries on retryable errors, then throws last error', async () => {
const retryable = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'boom')
client.invoke
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
await expect(llm.invoke([], {}, signal)).rejects.toBe(retryable)
// 1 initial + 2 retries = 3 attempts total
expect(client.invoke).toHaveBeenCalledTimes(3)
})
it('succeeds on retry after transient failure', async () => {
const retryable = new InvokeError(InvokeErrorTypes.RATE_LIMIT, 'slow down')
client.invoke.mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
it('emits "retry" events with attempt count and lastError', async () => {
const err1 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'first')
const err2 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'second')
client.invoke
.mockRejectedValueOnce(err1)
.mockRejectedValueOnce(err2)
.mockResolvedValueOnce('ok')
const events: { attempt: number; maxAttempts: number; lastError: Error }[] = []
llm.addEventListener('retry', (e) => {
events.push((e as CustomEvent).detail)
})
await llm.invoke([], {}, signal)
expect(events).toEqual([
{ attempt: 1, maxAttempts: 2, lastError: err1 },
{ attempt: 2, maxAttempts: 2, lastError: err2 },
])
})
it('does not retry on AbortError, throws immediately', async () => {
const err = abortError()
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (AUTH_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.AUTH_ERROR, 'bad token')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (CONFIG_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.CONFIG_ERROR, 'bad config')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('retries plain (non-InvokeError) errors as unknown failures', async () => {
// Plain errors are treated as retryable by withRetry (only InvokeError carries retryable flag)
const plain = new TypeError('weird')
client.invoke.mockRejectedValueOnce(plain).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
})
+21 -43
View File
@@ -1,9 +1,9 @@
import { OpenAIClient } from './OpenAIClient'
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
import { InvokeError, InvokeErrorType } from './errors'
import { InvokeError, InvokeErrorTypes } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
export { InvokeError, InvokeErrorType }
export { InvokeError, InvokeErrorTypes }
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
@@ -50,65 +50,43 @@ export class LLM extends EventTarget {
abortSignal: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> {
return await withRetry(
async () => {
// in case user aborted before invoking
if (abortSignal.aborted) throw new Error('AbortError')
const result = await this.client.invoke(messages, tools, abortSignal, options)
return result
return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
maxRetries: this.config.maxRetries,
onRetry: (attempt, lastError) => {
this.dispatchEvent(
new CustomEvent('retry', {
detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
})
)
},
// retry settings
{
maxRetries: this.config.maxRetries,
onRetry: (attempt: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
)
},
onError: (error: Error) => {
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
},
}
)
})
}
}
/**
* Retry a function until it succeeds or reaches the maximum number of retries.
*/
async function withRetry<T>(
fn: () => Promise<T>,
settings: {
maxRetries: number
onRetry: (attempt: number) => void
onError: (error: Error) => void
onRetry: (attempt: number, lastError: Error) => void
}
): Promise<T> {
let attempt = 0
let lastError: Error | null = null
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
while (true) {
try {
return await fn()
} catch (error: unknown) {
// do not retry if aborted by user
if ((error as any)?.rawError?.name === 'AbortError') throw error
console.error(error)
settings.onError(error as Error)
// do not retry if error is not retryable (InvokeError)
if ((error as any)?.name === 'AbortError') throw error
if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
attempt++
if (attempt > settings.maxRetries) throw error
console.debug('[LLM] retryable failure, will retry:', error)
settings.onRetry(attempt, error as Error)
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw lastError!
}
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest'
import { modelPatch } from './utils'
/**
* Baseline request body used as starting point for each provider test.
* Mirrors what OpenAIClient builds before calling modelPatch.
*/
function baseBody(model: string) {
return {
model,
temperature: 0.7,
messages: [],
tools: [],
parallel_tool_calls: false,
tool_choice: 'required' as unknown,
}
}
describe('modelPatch', () => {
it('returns body unchanged when model is missing', () => {
const body = { temperature: 0.7 }
expect(modelPatch(body)).toBe(body)
expect(body).toEqual({ temperature: 0.7 })
})
it('qwen: bumps temperature and disables thinking', () => {
const body = baseBody('qwen-max')
modelPatch(body)
expect(body.temperature).toBe(1.0)
expect(body).toMatchObject({ enable_thinking: false })
})
it('qwen: keeps higher caller-provided temperature', () => {
const body = baseBody('qwen-max')
body.temperature = 1.5
modelPatch(body)
expect(body.temperature).toBe(1.5)
})
it('claude: disables thinking and converts tool_choice "required" -> { type: "any" }', () => {
const body = baseBody('claude-3-5-sonnet')
modelPatch(body)
expect(body).toMatchObject({
thinking: { type: 'disabled' },
tool_choice: { type: 'any' },
})
})
it('claude: converts named tool_choice to { type: "tool", name }', () => {
const body = baseBody('claude-3-5-sonnet')
body.tool_choice = { type: 'function', function: { name: 'doStuff' } }
modelPatch(body)
expect(body.tool_choice).toEqual({ type: 'tool', name: 'doStuff' })
})
it('claude-opus-4-7: drops temperature', () => {
const body = baseBody('claude-opus-4-7')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-47 (alt id form): drops temperature', () => {
// Provider sometimes ships ids with the dot stripped; modelPatch normalizes.
const body = baseBody('claude-opus-47-20251029')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-4-8: drops temperature', () => {
const body = baseBody('claude-opus-4-8')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-48 (alt id form): drops temperature', () => {
const body = baseBody('claude-opus-48-20251210')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('grok: removes tool_choice and disables reasoning/thinking', () => {
const body = baseBody('grok-4')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
expect(body).toMatchObject({
thinking: { type: 'disabled', effort: 'minimal' },
reasoning: { enabled: false, effort: 'low' },
})
})
it('gpt-5: sets verbosity=low and reasoning_effort=low', () => {
const body = baseBody('gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
it('gpt-5-mini: low effort, temperature=1', () => {
const body = baseBody('gpt-5-mini')
modelPatch(body)
expect(body).toMatchObject({
verbosity: 'low',
reasoning_effort: 'low',
temperature: 1,
})
})
it('gpt-5.1 (gpt-51): disables reasoning', () => {
const body = baseBody('gpt-5.1')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'none' })
})
it('gpt-5.4 (gpt-54): drops reasoning_effort', () => {
const body = baseBody('gpt-5.4')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
})
it('gpt-5.5 (gpt-55): drops reasoning_effort and temperature', () => {
const body = baseBody('gpt-5.5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
expect(body).not.toHaveProperty('temperature')
})
it('gemini: sets reasoning_effort=minimal', () => {
const body = baseBody('gemini-2.5-pro')
modelPatch(body)
expect(body).toMatchObject({ reasoning_effort: 'minimal' })
})
it('deepseek: removes tool_choice', () => {
const body = baseBody('deepseek-chat')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
})
it('minimax: clamps temperature into (0, 1] and removes parallel_tool_calls', () => {
const body = baseBody('minimax-m2')
body.temperature = 0
modelPatch(body)
expect(body.temperature).toBeGreaterThan(0)
expect(body.temperature).toBeLessThanOrEqual(1)
expect(body).not.toHaveProperty('parallel_tool_calls')
})
it('minimax: caps temperature at 1', () => {
const body = baseBody('minimax-m2')
body.temperature = 2
modelPatch(body)
expect(body.temperature).toBe(1)
})
it('normalizes provider-prefixed model id (openai/gpt-5)', () => {
const body = baseBody('openai/gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
})
+7 -2
View File
@@ -56,8 +56,13 @@ export function modelPatch(body: Record<string, any>) {
// TODO: Claude naming pattern has changed
// needs proper handling
if (modelName.startsWith('claude-opus-4-7') || modelName.startsWith('claude-opus-47')) {
debug('Applying Claude-4.7 patch: remove temperature')
if (
modelName.startsWith('claude-opus-4-7') ||
modelName.startsWith('claude-opus-47') ||
modelName.startsWith('claude-opus-4-8') ||
modelName.startsWith('claude-opus-48')
) {
debug('Applying Claude-4.7/4.8 patch: remove temperature')
delete body.temperature
}
}
+2
View File
@@ -13,6 +13,8 @@ export default defineConfig({
clearScreen: false,
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true,
compilerOptions: {
composite: true,
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'llms',
include: ['src/**/*.test.ts'],
// Suppress console output from passing tests; failed tests still get their logs.
silent: 'passed-only',
},
})
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/mcp",
"private": false,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"bin": {
"page-agent-mcp": "src/index.js"
@@ -29,7 +29,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0",
"zod": "^4.3.6"
"ws": "^8.21.0",
"zod": "^4.4.3"
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "page-agent",
"private": false,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "./src/PageAgent.ts",
"types": "./src/PageAgent.ts",
@@ -54,16 +54,16 @@
"postpublish": "node ../../scripts/post-publish.js"
},
"dependencies": {
"@page-agent/core": "1.8.1",
"@page-agent/llms": "1.8.1",
"@page-agent/page-controller": "1.8.1",
"@page-agent/ui": "1.8.1",
"@page-agent/core": "1.9.0",
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.9.0",
"chalk": "^5.6.2"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.6"
"zod": "^4.4.3"
}
}
+32 -25
View File
@@ -3,8 +3,12 @@
*/
import { PageAgent, type PageAgentConfig } from './PageAgent'
const currentScript = document.currentScript as HTMLScriptElement | null
const currentScriptURL = currentScript?.src ? new URL(currentScript.src) : null
const autoInit = currentScriptURL?.searchParams.get('autoInit') !== 'false'
// Clean up existing instances to prevent multiple injections from bookmarklet
if (window.pageAgent) {
if (autoInit && window.pageAgent) {
window.pageAgent.dispose()
}
@@ -17,32 +21,35 @@ const DEMO_MODEL = 'qwen3.5-plus'
const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
const DEMO_API_KEY = 'NA'
const currentScript = document.currentScript as HTMLScriptElement | null
// in case document.x is not ready yet
setTimeout(() => {
let config: PageAgentConfig
if (autoInit) {
setTimeout(() => {
let config: PageAgentConfig
let showPanel = true
if (currentScript) {
console.log('🚀 page-agent.js detected current script:', currentScript.src)
const url = new URL(currentScript.src)
const model = url.searchParams.get('model') || DEMO_MODEL
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
config = { model, baseURL, apiKey, language }
} else {
console.log('🚀 page-agent.js no current script detected, using default demo config')
config = {
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
if (currentScriptURL) {
const url = currentScriptURL
const model = url.searchParams.get('model') || DEMO_MODEL
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
showPanel = ((url.searchParams.get('showPanel') as 'true' | 'false') || 'true') === 'true'
config = { model, baseURL, apiKey, language }
} else {
console.log('🚀 page-agent.js no current script detected, using default demo config')
config = {
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
}
}
}
// Create agent
window.pageAgent = new PageAgent(config)
window.pageAgent.panel.show()
// Create agent
window.pageAgent = new PageAgent(config)
if (showPanel) {
window.pageAgent.panel.show()
}
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
})
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
})
}
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false,
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true,
compilerOptions: {
composite: true,
-1
View File
@@ -3,7 +3,6 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/page-controller",
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "./src/PageController.ts",
"types": "./src/PageController.ts",
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false,
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true,
compilerOptions: {
composite: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/ui",
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
+28 -10
View File
@@ -68,7 +68,7 @@ export class Panel {
this.#i18n = new I18n(config.language ?? 'en-US')
// Set up askUser callback on agent
this.#agent.onAskUser = (question) => this.#askUser(question)
this.#agent.onAskUser = (question, options) => this.#askUser(question, options?.signal)
// Create UI elements
this.#wrapper = this.#createWrapper()
@@ -169,10 +169,12 @@ export class Panel {
}
/**
* Ask for user input (internal, called by agent via onAskUser)
* Ask for user input (internal, called by agent via onAskUser).
* Rejects when `signal` aborts (task stopped or disposed), cleaning up the
* question card and pending state so the agent loop can settle.
*/
#askUser(question: string): Promise<string> {
return new Promise((resolve) => {
#askUser(question: string, signal?: AbortSignal): Promise<string> {
return new Promise((resolve, reject) => {
// Set `waiting for user answer` state
this.#isWaitingForUserAnswer = true
this.#userAnswerResolver = resolve
@@ -195,6 +197,27 @@ export class Panel {
this.#scrollToBottom()
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
signal?.addEventListener(
'abort',
() => {
this.#removeTempCards()
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// reason is a DOMException AbortError (abort() takes no args).
reject(signal.reason as DOMException)
},
{ once: true }
)
})
}
/** Remove temporary question cards (only direct children for safety) */
#removeTempCards(): void {
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
})
}
@@ -307,12 +330,7 @@ export class Panel {
* Handle user answer
*/
#handleUserAnswer(input: string): void {
// Remove temporary question cards (only direct children for safety)
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
})
this.#removeTempCards()
// Reset state
this.#isWaitingForUserAnswer = false
+4 -2
View File
@@ -60,10 +60,12 @@ export interface PanelAgentAdapter extends EventTarget {
readonly task: string
/**
* Callback for when agent needs user input.
* Called when the agent needs to ask the user questions.
* If unset, the `ask_user` tool will be disabled.
* Panel will set this to handle user questions via its UI.
* The optional `signal` aborts when the task is stopped or disposed.
*/
onAskUser?: (question: string) => Promise<string>
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
/** Execute a task */
execute(task: string): Promise<unknown>
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false,
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true,
compilerOptions: {
composite: true,
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/website",
"private": true,
"version": "1.8.1",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -14,21 +14,21 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"lucide-react": "^1.16.0",
"motion": "^12.40.0",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"rough-notation": "^0.5.1",
"simple-icons": "^16.16.0",
"simple-icons": "^16.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
"wouter": "^3.10.0"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js'
'https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js'
'https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'qwen3.5-flash'
+2 -1
View File
@@ -22,7 +22,8 @@ interface NavSection {
export default function DocsLayout({ children }: DocsLayoutProps) {
const { isZh } = useLanguage()
const [location] = useLocation()
const [rawLocation] = useLocation()
const location = rawLocation.replace(/\/+$/, '')
const navigationSections: NavSection[] = [
{
@@ -257,7 +257,8 @@ const root = createRoot(document.getElementById('my-ui')!)
root.render(<MyAgentUI agent={agent} />)
// 4. Handle user input (optional)
agent.onAskUser = async (question) => window.prompt(question) || ''
// options.signal aborts when the task is stopped or disposed
agent.onAskUser = async (question, options) => window.prompt(question) || ''
// 5. Execute task
await agent.execute('Fill the form with test data')
@@ -354,10 +354,10 @@ const result = await agent.execute('Fill in the form with test data')`}
},
{
name: 'onAskUser',
type: '(question: string) => Promise<string>',
type: '(question: string, options?: { signal: AbortSignal }) => Promise<string>',
description: isZh
? 'Agent 需要用户输入时的回调。未设置则禁用 ask_user 工具。'
: 'Callback when agent needs user input. If not set, ask_user tool is disabled.',
? '当 agent 需要用户提问时调用。未设置则禁用 `ask_user` 工具。实现应在 options.signal 触发 abort 时 reject promise。'
: 'Called when the agent needs to ask the user questions. If unset, the `ask_user` tool will be disabled. Implementations should reject the promise when options.signal aborts.',
},
]}
/>
@@ -373,8 +373,8 @@ const result = await agent.execute('Fill in the form with test data')`}
name: 'execute(task)',
type: 'Promise<ExecutionResult>',
description: isZh
? '执行任务并返回结果包含 success、data 和 history 字段。'
: 'Execute a task and return result. Contains success, data, and history fields.',
? '执行任务并返回结果包含 success、data 和 history 字段)。若已有任务在运行则抛出错误——不支持并发执行。'
: 'Execute a task and return result (contains success, data, and history fields). Throws if a task is already running — concurrent execution is not supported.',
},
{
name: 'stop()',
@@ -38,8 +38,8 @@ import { z } from 'zod/v4'`}
</Heading>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。'
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute.'}
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。异步工具必须 honor ctx.signal。'
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute. Async tools must honor ctx.signal.'}
</p>
<CodeEditor
@@ -56,10 +56,11 @@ const pageAgent = new PageAgent({
productId: z.string(),
quantity: z.number().min(1).default(1),
}),
execute: async function (input) {
execute: async function (input, { signal }) {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(input),
signal, // honor cancellation
})
return \`Added \${input.quantity}x \${input.productId} to cart.\`
},
@@ -72,9 +73,10 @@ const pageAgent = new PageAgent({
query: z.string(),
limit: z.number().max(10).default(3),
}),
execute: async function (input) {
execute: async function (input, { signal }) {
const res = await fetch(
\`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\`
\`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\`,
{ signal }
)
const articles = await res.json()
return JSON.stringify(articles)
@@ -9,7 +9,7 @@ const BASELINE = new Set([
'gpt-5.1',
'gpt-5.4-mini',
'claude-haiku-4.5',
'gemini-3.1-flash-lite',
'gemini-3.5-flash',
'deepseek-v4-flash',
'qwen3.5-plus',
'qwen3.5-flash',
@@ -18,6 +18,8 @@ const BASELINE = new Set([
// Models grouped by brand, newest first
const MODEL_GROUPS: Record<string, string[]> = {
Qwen: [
'qwen3.7-max',
'qwen3.7-plus',
'qwen3.6-max',
'qwen3.6-plus',
'qwen3.6-flash',
@@ -40,8 +42,15 @@ const MODEL_GROUPS: Record<string, string[]> = {
'gpt-4.1-mini',
],
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
Google: ['gemini-3.1-flash-lite', 'gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
Google: [
'gemini-3.5-flash',
'gemini-3.1-flash-lite',
'gemini-3-pro',
'gemini-3-flash',
'gemini-2.5',
],
Anthropic: [
'claude-opus-4.8',
'claude-opus-4.7',
'claude-opus-4.6',
'claude-opus-4.5',
@@ -58,6 +58,11 @@ export default function QuickStart() {
code={`<script src="DEMO_CDN_URL" crossorigin="true"></script>`}
language="html"
/>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{isZh
? '在 URL 后添加 ?autoInit=false 可只加载脚本,不自动创建 Demo Agent;之后可通过 new window.PageAgent(...) 手动初始化。'
: 'Add ?autoInit=false to load the script without creating the demo agent automatically. You can then instantiate it with new window.PageAgent(...).'}
</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
+5 -3
View File
@@ -1,5 +1,5 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc'
import react from '@vitejs/plugin-react'
import { config as dotenvConfig } from 'dotenv'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import process from 'node:process'
@@ -82,8 +82,10 @@ export default defineConfig(({ mode }) => ({
handler(message)
},
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'wouter'],
manualChunks(id) {
if (/[\\/]node_modules[\\/](react|react-dom|wouter)([\\/]|$)/.test(id)) {
return 'vendor'
}
},
},
},
+3 -2
View File
@@ -36,13 +36,14 @@ if (isMainBranch()) {
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
}
// 2. Lint + Format + Typecheck in parallel
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck '))
// 2. Lint + Format + Typecheck + Test in parallel
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck + test '))
await parallelTask(
[
{ label: 'lint', command: 'npm run lint' },
{ label: 'format', command: 'npx prettier --check .' },
{ label: 'typecheck', command: 'npm run typecheck' },
{ label: 'test', command: 'npm test' },
],
{ timeoutMs: 120_000 }
)