Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a2f0a41ec | |||
| 64bb1f1a56 | |||
| 2afcf13fbb | |||
| f617728fc3 | |||
| 9376abfe2a | |||
| 2f343bcc56 | |||
| 4108f67079 | |||
| 0ba3bbd67f | |||
| 552987eb56 | |||
| 99ef08ac79 | |||
| 4445bec08a | |||
| 6530f0ef40 | |||
| f8676a5cc2 | |||
| 029f64e4bc | |||
| 78b6e2ad3c | |||
| 112f9fff39 | |||
| 1c82030a21 | |||
| f378972ef8 | |||
| 68ae73d4e6 | |||
| 874b302860 | |||
| 90ff96ca15 | |||
| 3c0d55fa33 | |||
| 9891e02215 | |||
| 823195ad94 | |||
| fd12fb9f1b | |||
| fb8de08c39 | |||
| 738a62e96c | |||
| e7b93efda0 | |||
| 0b6f0a6068 | |||
| cc16d39e00 | |||
| 7eceff26ef | |||
| 61a791f759 | |||
| 6ecd6e61f6 | |||
| 1e13385f1f | |||
| dec9fe8b3d | |||
| ebd8ec5ae7 | |||
| 482190777c |
@@ -23,6 +23,7 @@ npm run build # Build all packages
|
|||||||
npm run build:libs # Build all libraries
|
npm run build:libs # Build all libraries
|
||||||
npm run build:ext # Build and zip the extension package
|
npm run build:ext # Build and zip the extension package
|
||||||
npm run typecheck # Typecheck all packages
|
npm run typecheck # Typecheck all packages
|
||||||
|
npm run test # Run unit tests across all workspaces
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,6 +126,20 @@ const pageInfo = await this.pageController.getPageInfo()
|
|||||||
2. Expose via async method in `PageController.ts`
|
2. Expose via async method in `PageController.ts`
|
||||||
3. Export from `packages/page-controller/src/index.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
|
## Code Standards
|
||||||
|
|
||||||
- Explicit typing for exported/public APIs
|
- Explicit typing for exported/public APIs
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.2/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.8.2/files/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(...)`.
|
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -49,8 +49,8 @@
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.2/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.8.2/files/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(...)` 手动初始化。
|
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
|
||||||
|
|
||||||
|
|||||||
Generated
+1278
-953
File diff suppressed because it is too large
Load Diff
+26
-18
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.13.0 || >=24",
|
"node": "^22.22.1 || >=24",
|
||||||
"npm": "^11.6.3"
|
"npm": "^11.6.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -36,37 +36,45 @@
|
|||||||
"version": "node scripts/sync-version.js",
|
"version": "node scripts/sync-version.js",
|
||||||
"postpublish": "npm run postpublish --workspaces --if-present",
|
"postpublish": "npm run postpublish --workspaces --if-present",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
|
||||||
|
"test": "npm test --workspaces --if-present",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"ci": "node scripts/ci.js",
|
"ci": "node scripts/ci.js",
|
||||||
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
||||||
"prepare": "husky || true"
|
"prepare": "husky || true"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.5.3",
|
"@commitlint/cli": "^21.0.1",
|
||||||
"@commitlint/config-conventional": "^20.5.3",
|
"@commitlint/config-conventional": "^21.0.1",
|
||||||
"@eslint-react/eslint-plugin": "^4.2.3",
|
"@eslint-react/eslint-plugin": "^5.8.6",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@microsoft/api-extractor": "^7.58.7",
|
"@microsoft/api-extractor": "^7.58.7",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.9.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^10.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.4.0",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^17.0.5",
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.60.0",
|
||||||
"unplugin-dts": "^1.0.0",
|
"unplugin-dts": "^1.0.1",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.14",
|
||||||
"vite-plugin-css-injected-by-js": "^5.0.1"
|
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.3",
|
||||||
|
"node-notifier": {
|
||||||
|
"uuid": "11.1.1"
|
||||||
|
},
|
||||||
|
"web-ext-run": {
|
||||||
|
"tmp": "0.2.7"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/core",
|
"name": "@page-agent/core",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/PageAgentCore.ts",
|
"main": "./src/PageAgentCore.ts",
|
||||||
"types": "./src/PageAgentCore.ts",
|
"types": "./src/PageAgentCore.ts",
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"@page-agent/llms": "1.8.2",
|
"@page-agent/llms": "1.9.0",
|
||||||
"@page-agent/page-controller": "1.8.2"
|
"@page-agent/page-controller": "1.9.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
|||||||
@@ -73,14 +73,21 @@ export class PageAgentCore extends EventTarget {
|
|||||||
disposed = false
|
disposed = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when agent needs user input (ask_user tool)
|
* Called when the agent needs to ask the user questions.
|
||||||
* If not set, ask_user tool will be disabled
|
* If unset, the `ask_user` tool will be disabled.
|
||||||
|
* Implementations should reject the promise when `signal` aborts.
|
||||||
* @example onAskUser: (q) => window.prompt(q) || ''
|
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||||
*/
|
*/
|
||||||
onAskUser?: (question: string) => Promise<string>
|
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
|
||||||
|
|
||||||
#status: AgentStatus = 'idle'
|
#status: AgentStatus = 'idle'
|
||||||
#llm: LLM
|
#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()
|
#abortController = new AbortController()
|
||||||
#observations: string[] = []
|
#observations: string[] = []
|
||||||
|
|
||||||
@@ -103,11 +110,14 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.tools = new Map(tools)
|
this.tools = new Map(tools)
|
||||||
this.pageController = config.pageController
|
this.pageController = config.pageController
|
||||||
|
|
||||||
// Listen to LLM retry events
|
|
||||||
this.#llm.addEventListener('retry', (e) => {
|
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 })
|
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({
|
this.history.push({
|
||||||
type: 'retry',
|
type: 'retry',
|
||||||
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||||
@@ -116,19 +126,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
})
|
})
|
||||||
this.#emitHistoryChange()
|
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) {
|
if (this.config.customTools) {
|
||||||
for (const [name, tool] of Object.entries(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> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
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')
|
if (!task) throw new Error('Task is required')
|
||||||
|
|
||||||
this.task = task
|
this.task = task
|
||||||
this.taskId = uid()
|
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
|
// Disable ask_user tool if onAskUser is not set
|
||||||
if (!this.onAskUser) {
|
if (!this.onAskUser) {
|
||||||
this.tools.delete('ask_user')
|
this.tools.delete('ask_user')
|
||||||
@@ -209,25 +216,17 @@ export class PageAgentCore extends EventTarget {
|
|||||||
const onBeforeTask = this.config.onBeforeTask
|
const onBeforeTask = this.config.onBeforeTask
|
||||||
const onAfterTask = this.config.onAfterTask
|
const onAfterTask = this.config.onAfterTask
|
||||||
|
|
||||||
await onBeforeTask?.(this)
|
try {
|
||||||
|
await onBeforeTask?.(this)
|
||||||
// Show mask
|
await this.pageController.showMask()
|
||||||
await this.pageController.showMask()
|
} catch (error) {
|
||||||
|
this.#setStatus('error')
|
||||||
if (this.#abortController) {
|
throw error
|
||||||
this.#abortController.abort()
|
|
||||||
this.#abortController = new AbortController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history = []
|
|
||||||
this.#setStatus('running')
|
|
||||||
this.#emitHistoryChange()
|
|
||||||
this.#observations = []
|
|
||||||
|
|
||||||
// Reset internal states
|
|
||||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
|
let taskSuccess: boolean
|
||||||
|
let taskResult: string
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -289,63 +288,49 @@ export class PageAgentCore extends EventTarget {
|
|||||||
} as AgentStepEvent)
|
} as AgentStepEvent)
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
await onAfterStep?.(this, this.history)
|
await onAfterStep?.(this, this.history)
|
||||||
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
|
|
||||||
// finish task if done
|
|
||||||
|
|
||||||
if (actionName === 'done') {
|
if (actionName === 'done') {
|
||||||
const success = action.input?.success ?? false
|
taskSuccess = action.input?.success ?? false
|
||||||
const text = action.input?.text || 'no text provided'
|
taskResult = action.input?.text || 'no text provided'
|
||||||
console.log(chalk.green.bold('Task completed'), success, text)
|
console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult)
|
||||||
this.#onDone(success)
|
break
|
||||||
const result: ExecutionResult = {
|
|
||||||
success,
|
|
||||||
data: text,
|
|
||||||
history: this.history,
|
|
||||||
}
|
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.groupEnd() // to prevent nested groups
|
console.groupEnd()
|
||||||
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
|
const isAbortError = (error as any)?.name === 'AbortError'
|
||||||
|
if (!isAbortError) console.error('Task failed', error)
|
||||||
console.error('Task failed', error)
|
taskResult = isAbortError ? 'Task aborted' : String(error)
|
||||||
const errorMessage = isAbortError ? 'Task stopped' : String(error)
|
taskSuccess = false
|
||||||
this.#emitActivity({ type: 'error', message: errorMessage })
|
this.#emitActivity({ type: 'error', message: taskResult })
|
||||||
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
|
this.history.push({ type: 'error', message: taskResult, rawResponse: error })
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
this.#onDone(false)
|
break
|
||||||
const result: ExecutionResult = {
|
|
||||||
success: false,
|
|
||||||
data: errorMessage,
|
|
||||||
history: this.history,
|
|
||||||
}
|
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
step++
|
step++
|
||||||
if (step > this.config.maxSteps) {
|
if (step > this.config.maxSteps) {
|
||||||
const errorMessage = 'Step count exceeded maximum limit'
|
taskResult = 'Step count exceeded maximum limit'
|
||||||
this.history.push({ type: 'error', message: errorMessage })
|
taskSuccess = false
|
||||||
|
this.#emitActivity({ type: 'error', message: taskResult })
|
||||||
|
this.history.push({ type: 'error', message: taskResult })
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
this.#onDone(false)
|
break
|
||||||
const result: ExecutionResult = {
|
|
||||||
success: false,
|
|
||||||
data: errorMessage,
|
|
||||||
history: this.history,
|
|
||||||
}
|
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitFor(this.config.stepDelay ?? 0.4)
|
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!',
|
description: 'You MUST call this tool every step!',
|
||||||
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
||||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
// abort
|
const signal = this.#abortController.signal
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
signal.throwIfAborted()
|
||||||
|
|
||||||
console.log(chalk.blue.bold('MacroTool input'), input)
|
console.log(chalk.blue.bold('MacroTool input'), input)
|
||||||
const action = input.action
|
const action = input.action
|
||||||
@@ -411,8 +396,9 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Execute tool, bind `this` to PageAgent
|
const result = await tool.execute.bind(this)(toolInput, { signal })
|
||||||
const result = await tool.execute.bind(this)(toolInput)
|
// Enforce abort even if the tool ignored the signal and resolved normally.
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
|
|||||||
import type { PageAgentCore } from '../PageAgentCore'
|
import type { PageAgentCore } from '../PageAgentCore'
|
||||||
import { waitFor } from '../utils'
|
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
|
* Internal tool definition that has access to PageAgent `this` context
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
|
|||||||
// name: string
|
// name: string
|
||||||
description: string
|
description: string
|
||||||
inputSchema: z.ZodType<TParams>
|
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> {
|
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||||
@@ -50,12 +58,12 @@ tools.set(
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
seconds: z.number().min(1).max(10).default(1),
|
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
|
// try to subtract LLM calling time from the actual wait time
|
||||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||||
await waitFor(actualWaitTime)
|
await waitFor(actualWaitTime, signal)
|
||||||
|
|
||||||
return `✅ Waited for ${input.seconds} seconds.`
|
return `✅ Waited for ${input.seconds} seconds.`
|
||||||
},
|
},
|
||||||
@@ -70,11 +78,11 @@ tools.set(
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
if (!this.onAskUser) {
|
if (!this.onAskUser) {
|
||||||
throw new Error('ask_user tool requires onAskUser callback to be set')
|
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}`
|
return `User answered: ${answer}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,28 @@ import chalk from 'chalk'
|
|||||||
|
|
||||||
export * from './autoFixer'
|
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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
bundleTypes: true,
|
bundleTypes: true,
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
composite: true,
|
composite: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ext",
|
"name": "@page-agent/ext",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -18,30 +18,30 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/chrome": "^0.1.42",
|
"@types/chrome": "^0.1.42",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@wxt-dev/module-react": "^1.2.2",
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.40.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.19.0",
|
"simple-icons": "^16.21.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wxt": "^0.20.25"
|
"wxt": "^0.20.26"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.8.2",
|
"@page-agent/core": "1.9.0",
|
||||||
"@page-agent/llms": "1.8.2",
|
"@page-agent/llms": "1.9.0",
|
||||||
"@page-agent/page-controller": "1.8.2",
|
"@page-agent/page-controller": "1.9.0",
|
||||||
"@page-agent/ui": "1.8.2",
|
"@page-agent/ui": "1.9.0",
|
||||||
"ai-motion": "^0.4.8",
|
"ai-motion": "^0.4.8",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/llms",
|
"name": "@page-agent/llms",
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
"postpublish": "node ../../scripts/post-publish.js"
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,8 @@ export class OpenAIClient implements LLMClient {
|
|||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
options?: InvokeOptions
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
|
abortSignal?.throwIfAborted()
|
||||||
|
|
||||||
// 1. Convert tools to OpenAI format
|
// 1. Convert tools to OpenAI format
|
||||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||||
|
|
||||||
@@ -70,17 +72,20 @@ export class OpenAIClient implements LLMClient {
|
|||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const isAbortError = (error as any)?.name === 'AbortError'
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
|
console.error(error)
|
||||||
if (!isAbortError) console.error(error)
|
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
|
||||||
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, errorMessage, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle HTTP errors
|
// 3. Handle HTTP errors
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch()
|
let errorData: any
|
||||||
const errorMessage =
|
try {
|
||||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
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) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
@@ -111,11 +116,21 @@ export class OpenAIClient implements LLMClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Parse and validate response
|
// 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]
|
const choice = data.choices?.[0]
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
throw new InvokeError(InvokeErrorTypes.UNKNOWN, 'No choices in response', data)
|
throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check finish_reason
|
// Check finish_reason
|
||||||
@@ -140,7 +155,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorTypes.UNKNOWN,
|
InvokeErrorTypes.INVALID_SCHEMA,
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
@@ -212,11 +227,12 @@ export class OpenAIClient implements LLMClient {
|
|||||||
let toolResult: unknown
|
let toolResult: unknown
|
||||||
try {
|
try {
|
||||||
toolResult = await tool.execute(toolInput)
|
toolResult = await tool.execute(toolInput)
|
||||||
} catch (e) {
|
} catch (error: unknown) {
|
||||||
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
||||||
`Tool execution failed: ${(e as Error).message}`,
|
`Tool execution failed: ${(error as Error)?.message}`,
|
||||||
e,
|
error,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-18
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Error types and error handling for LLM invocations
|
* Error types and error handling for LLM invocations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const InvokeErrorTypes = {
|
export const InvokeErrorTypes = {
|
||||||
@@ -10,6 +10,8 @@ export const InvokeErrorTypes = {
|
|||||||
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
||||||
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
||||||
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
|
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',
|
UNKNOWN: 'unknown',
|
||||||
|
|
||||||
@@ -22,6 +24,18 @@ export const InvokeErrorTypes = {
|
|||||||
|
|
||||||
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
|
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 {
|
export class InvokeError extends Error {
|
||||||
type: InvokeErrorType
|
type: InvokeErrorType
|
||||||
retryable: boolean
|
retryable: boolean
|
||||||
@@ -35,24 +49,8 @@ export class InvokeError extends Error {
|
|||||||
super(message)
|
super(message)
|
||||||
this.name = 'InvokeError'
|
this.name = 'InvokeError'
|
||||||
this.type = type
|
this.type = type
|
||||||
this.retryable = this.isRetryable(type, rawError)
|
this.retryable = RETRYABLE_TYPES.includes(type)
|
||||||
this.rawError = rawError
|
this.rawError = rawError
|
||||||
this.rawResponse = rawResponse
|
this.rawResponse = rawResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
|
|
||||||
const isAbortError = (rawError as any)?.name === 'AbortError'
|
|
||||||
if (isAbortError) return false
|
|
||||||
|
|
||||||
const retryableTypes: InvokeErrorType[] = [
|
|
||||||
InvokeErrorTypes.NETWORK_ERROR,
|
|
||||||
InvokeErrorTypes.RATE_LIMIT,
|
|
||||||
InvokeErrorTypes.SERVER_ERROR,
|
|
||||||
InvokeErrorTypes.NO_TOOL_CALL,
|
|
||||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
|
||||||
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
|
||||||
InvokeErrorTypes.UNKNOWN,
|
|
||||||
]
|
|
||||||
return retryableTypes.includes(type)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+19
-41
@@ -50,65 +50,43 @@ export class LLM extends EventTarget {
|
|||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
options?: InvokeOptions
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
return await withRetry(
|
return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
|
||||||
async () => {
|
maxRetries: this.config.maxRetries,
|
||||||
// in case user aborted before invoking
|
onRetry: (attempt, lastError) => {
|
||||||
if (abortSignal.aborted) throw new Error('AbortError')
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('retry', {
|
||||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
|
||||||
|
})
|
||||||
return result
|
)
|
||||||
},
|
},
|
||||||
// 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>(
|
async function withRetry<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
settings: {
|
settings: {
|
||||||
maxRetries: number
|
maxRetries: number
|
||||||
onRetry: (attempt: number) => void
|
onRetry: (attempt: number, lastError: Error) => void
|
||||||
onError: (error: Error) => void
|
|
||||||
}
|
}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
let lastError: Error | null = null
|
while (true) {
|
||||||
while (attempt <= settings.maxRetries) {
|
|
||||||
if (attempt > 0) {
|
|
||||||
settings.onRetry(attempt)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// do not retry if aborted by user
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
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 instanceof InvokeError && !error.retryable) throw error
|
if (error instanceof InvokeError && !error.retryable) throw error
|
||||||
|
|
||||||
lastError = error as Error
|
|
||||||
attempt++
|
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))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError!
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -56,8 +56,13 @@ export function modelPatch(body: Record<string, any>) {
|
|||||||
|
|
||||||
// TODO: Claude naming pattern has changed
|
// TODO: Claude naming pattern has changed
|
||||||
// needs proper handling
|
// needs proper handling
|
||||||
if (modelName.startsWith('claude-opus-4-7') || modelName.startsWith('claude-opus-47')) {
|
if (
|
||||||
debug('Applying Claude-4.7 patch: remove temperature')
|
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
|
delete body.temperature
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default defineConfig({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
bundleTypes: true,
|
bundleTypes: true,
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
composite: true,
|
composite: true,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/mcp",
|
"name": "@page-agent/mcp",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"page-agent-mcp": "src/index.js"
|
"page-agent-mcp": "src/index.js"
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/PageAgent.ts",
|
"main": "./src/PageAgent.ts",
|
||||||
"types": "./src/PageAgent.ts",
|
"types": "./src/PageAgent.ts",
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
"postpublish": "node ../../scripts/post-publish.js"
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.8.2",
|
"@page-agent/core": "1.9.0",
|
||||||
"@page-agent/llms": "1.8.2",
|
"@page-agent/llms": "1.9.0",
|
||||||
"@page-agent/page-controller": "1.8.2",
|
"@page-agent/page-controller": "1.9.0",
|
||||||
"@page-agent/ui": "1.8.2",
|
"@page-agent/ui": "1.9.0",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
bundleTypes: true,
|
bundleTypes: true,
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
composite: true,
|
composite: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/PageController.ts",
|
"main": "./src/PageController.ts",
|
||||||
"types": "./src/PageController.ts",
|
"types": "./src/PageController.ts",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default defineConfig({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
bundleTypes: true,
|
bundleTypes: true,
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
composite: true,
|
composite: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ui",
|
"name": "@page-agent/ui",
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class Panel {
|
|||||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||||
|
|
||||||
// Set up askUser callback on agent
|
// 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
|
// Create UI elements
|
||||||
this.#wrapper = this.#createWrapper()
|
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> {
|
#askUser(question: string, signal?: AbortSignal): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Set `waiting for user answer` state
|
// Set `waiting for user answer` state
|
||||||
this.#isWaitingForUserAnswer = true
|
this.#isWaitingForUserAnswer = true
|
||||||
this.#userAnswerResolver = resolve
|
this.#userAnswerResolver = resolve
|
||||||
@@ -195,6 +197,27 @@ export class Panel {
|
|||||||
this.#scrollToBottom()
|
this.#scrollToBottom()
|
||||||
|
|
||||||
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
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
|
* Handle user answer
|
||||||
*/
|
*/
|
||||||
#handleUserAnswer(input: string): void {
|
#handleUserAnswer(input: string): void {
|
||||||
// Remove temporary question cards (only direct children for safety)
|
this.#removeTempCards()
|
||||||
Array.from(this.#historySection.children).forEach((child) => {
|
|
||||||
if (child.getAttribute('data-temp-card') === 'true') {
|
|
||||||
child.remove()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
this.#isWaitingForUserAnswer = false
|
this.#isWaitingForUserAnswer = false
|
||||||
|
|||||||
@@ -60,10 +60,12 @@ export interface PanelAgentAdapter extends EventTarget {
|
|||||||
readonly task: string
|
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.
|
* 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 a task */
|
||||||
execute(task: string): Promise<unknown>
|
execute(task: string): Promise<unknown>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default defineConfig({
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
bundleTypes: true,
|
bundleTypes: true,
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
composite: true,
|
composite: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.2",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -14,21 +14,21 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.16.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.40.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.19.0",
|
"simple-icons": "^16.21.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Demo build (auto-init with demo LLM, for quick testing)
|
// Demo build (auto-init with demo LLM, for quick testing)
|
||||||
export const CDN_DEMO_URL =
|
export const CDN_DEMO_URL =
|
||||||
'https://cdn.jsdelivr.net/npm/page-agent@1.8.2/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 =
|
export const CDN_DEMO_CN_URL =
|
||||||
'https://registry.npmmirror.com/page-agent/1.8.2/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)
|
// Demo LLM for website testing (homepage quick trial uses flash)
|
||||||
export const DEMO_MODEL = 'qwen3.5-flash'
|
export const DEMO_MODEL = 'qwen3.5-flash'
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ interface NavSection {
|
|||||||
|
|
||||||
export default function DocsLayout({ children }: DocsLayoutProps) {
|
export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||||
const { isZh } = useLanguage()
|
const { isZh } = useLanguage()
|
||||||
const [location] = useLocation()
|
const [rawLocation] = useLocation()
|
||||||
|
const location = rawLocation.replace(/\/+$/, '')
|
||||||
|
|
||||||
const navigationSections: NavSection[] = [
|
const navigationSections: NavSection[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -257,7 +257,8 @@ const root = createRoot(document.getElementById('my-ui')!)
|
|||||||
root.render(<MyAgentUI agent={agent} />)
|
root.render(<MyAgentUI agent={agent} />)
|
||||||
|
|
||||||
// 4. Handle user input (optional)
|
// 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
|
// 5. Execute task
|
||||||
await agent.execute('Fill the form with test data')
|
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',
|
name: 'onAskUser',
|
||||||
type: '(question: string) => Promise<string>',
|
type: '(question: string, options?: { signal: AbortSignal }) => Promise<string>',
|
||||||
description: isZh
|
description: isZh
|
||||||
? 'Agent 需要用户输入时的回调。未设置则禁用 ask_user 工具。'
|
? '当 agent 需要向用户提问时调用。未设置则禁用 `ask_user` 工具。实现应在 options.signal 触发 abort 时 reject promise。'
|
||||||
: 'Callback when agent needs user input. If not set, ask_user tool is 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 options.signal aborts.',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -373,8 +373,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
name: 'execute(task)',
|
name: 'execute(task)',
|
||||||
type: 'Promise<ExecutionResult>',
|
type: 'Promise<ExecutionResult>',
|
||||||
description: isZh
|
description: isZh
|
||||||
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
? '执行任务并返回结果(包含 success、data 和 history 字段)。若已有任务在运行则抛出错误——不支持并发执行。'
|
||||||
: 'Execute a task and return result. Contains success, data, and history fields.',
|
: '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()',
|
name: 'stop()',
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import { z } from 'zod/v4'`}
|
|||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
{isZh
|
{isZh
|
||||||
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。'
|
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。异步工具必须 honor ctx.signal。'
|
||||||
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute.'}
|
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute. Async tools must honor ctx.signal.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
@@ -56,10 +56,11 @@ const pageAgent = new PageAgent({
|
|||||||
productId: z.string(),
|
productId: z.string(),
|
||||||
quantity: z.number().min(1).default(1),
|
quantity: z.number().min(1).default(1),
|
||||||
}),
|
}),
|
||||||
execute: async function (input) {
|
execute: async function (input, { signal }) {
|
||||||
await fetch('/api/cart', {
|
await fetch('/api/cart', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
|
signal, // honor cancellation
|
||||||
})
|
})
|
||||||
return \`Added \${input.quantity}x \${input.productId} to cart.\`
|
return \`Added \${input.quantity}x \${input.productId} to cart.\`
|
||||||
},
|
},
|
||||||
@@ -72,9 +73,10 @@ const pageAgent = new PageAgent({
|
|||||||
query: z.string(),
|
query: z.string(),
|
||||||
limit: z.number().max(10).default(3),
|
limit: z.number().max(10).default(3),
|
||||||
}),
|
}),
|
||||||
execute: async function (input) {
|
execute: async function (input, { signal }) {
|
||||||
const res = await fetch(
|
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()
|
const articles = await res.json()
|
||||||
return JSON.stringify(articles)
|
return JSON.stringify(articles)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const BASELINE = new Set([
|
|||||||
'gpt-5.1',
|
'gpt-5.1',
|
||||||
'gpt-5.4-mini',
|
'gpt-5.4-mini',
|
||||||
'claude-haiku-4.5',
|
'claude-haiku-4.5',
|
||||||
'gemini-3.1-flash-lite',
|
'gemini-3.5-flash',
|
||||||
'deepseek-v4-flash',
|
'deepseek-v4-flash',
|
||||||
'qwen3.5-plus',
|
'qwen3.5-plus',
|
||||||
'qwen3.5-flash',
|
'qwen3.5-flash',
|
||||||
@@ -18,6 +18,8 @@ const BASELINE = new Set([
|
|||||||
// Models grouped by brand, newest first
|
// Models grouped by brand, newest first
|
||||||
const MODEL_GROUPS: Record<string, string[]> = {
|
const MODEL_GROUPS: Record<string, string[]> = {
|
||||||
Qwen: [
|
Qwen: [
|
||||||
|
'qwen3.7-max',
|
||||||
|
'qwen3.7-plus',
|
||||||
'qwen3.6-max',
|
'qwen3.6-max',
|
||||||
'qwen3.6-plus',
|
'qwen3.6-plus',
|
||||||
'qwen3.6-flash',
|
'qwen3.6-flash',
|
||||||
@@ -40,8 +42,15 @@ const MODEL_GROUPS: Record<string, string[]> = {
|
|||||||
'gpt-4.1-mini',
|
'gpt-4.1-mini',
|
||||||
],
|
],
|
||||||
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
|
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: [
|
Anthropic: [
|
||||||
|
'claude-opus-4.8',
|
||||||
'claude-opus-4.7',
|
'claude-opus-4.7',
|
||||||
'claude-opus-4.6',
|
'claude-opus-4.6',
|
||||||
'claude-opus-4.5',
|
'claude-opus-4.5',
|
||||||
|
|||||||
+3
-2
@@ -36,13 +36,14 @@ if (isMainBranch()) {
|
|||||||
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
|
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lint + Format + Typecheck in parallel
|
// 2. Lint + Format + Typecheck + Test in parallel
|
||||||
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck '))
|
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck + test '))
|
||||||
await parallelTask(
|
await parallelTask(
|
||||||
[
|
[
|
||||||
{ label: 'lint', command: 'npm run lint' },
|
{ label: 'lint', command: 'npm run lint' },
|
||||||
{ label: 'format', command: 'npx prettier --check .' },
|
{ label: 'format', command: 'npx prettier --check .' },
|
||||||
{ label: 'typecheck', command: 'npm run typecheck' },
|
{ label: 'typecheck', command: 'npm run typecheck' },
|
||||||
|
{ label: 'test', command: 'npm test' },
|
||||||
],
|
],
|
||||||
{ timeoutMs: 120_000 }
|
{ timeoutMs: 120_000 }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user