Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/features/experimental-tool-modes/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ Agent Mode 是兼容基线。工具 registry、disabled-tool 配置、权限、
Code Mode 保留当前已启用工具的能力,但不把这些工具逐个发送给模型:

- `openai-codex` 路由使用 Codex 风格的 raw JavaScript `exec` 和续跑 `wait`;
- 其他 function-tool 路由只发送 `run_code`,参数固定为
`{ code: string, description: string }`;
- Function-tool routes expose `run_code` with
`{ code: string, description: string, timeout_ms?: number }`;
- 两种入口都进入同一个 `RunCodeRuntimeManager`,不存在 `transport` 或第二套 runtime;
- function-tool 路由把当前目录生成成 TypeScript SDK,Codex 路由把嵌套声明写入 `exec`
描述;
Expand Down Expand Up @@ -188,11 +188,18 @@ IPC allowlist、V8 内存限制、heartbeat 和强制回收的组合。
| READY | 5 秒 |
| heartbeat 丢失 | 约 3.5 秒后终止 |
| VM 同步执行 slice | 2 秒 |
| cell 总执行时间 | 5 分钟 |
| cell 总执行时间 | Default 5 minutes; optional `timeout_ms` override |
| yielded / permission cell lease | 60 秒 |
| RSS hard ceiling | 512 MiB |
| STOP grace | 500 ms 后 `kill()` |

`timeout_ms` is a positive integer in milliseconds, up to `2147483647`. Function-tool routes pass
it as an optional `run_code` argument; Codex routes use the first-line `exec` pragma. Models omit
it for routine work and request a larger value only when the operation is expected to exceed five
minutes. The deadline covers the cell and its awaited subtools, is not reset by `wait` or permission
continuation, and does not extend a subtool's own timeout. User cancellation remains independent
of the selected duration. Invalid timeout values fail before dispatch.

成功、异常、取消、超时、进程退出、会话清理和应用退出都进入同一个幂等 cleanup:取消嵌套
调用,清理 timer/listener/active map,发送 `STOP`,超时后强制 `kill()`。失败 cell 不自动
重放,避免重复执行 Shell、文件或 MCP 副作用。
Expand Down
92 changes: 92 additions & 0 deletions docs/issues/agent-cancellation-code-timeout/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Agent Cancellation and Code Cell Timeouts

## Context

[Issue #2221](https://github.com/ThinkInAIXYZ/deepchat/issues/2221) reports a Code Mode run that
stopped progressing and could not be stopped by the user. Its partial trace records a provider
429 followed by a scheduled retry; it does not identify the complete incident sequence.

Independent reproduction identifies two cancellation boundaries that require protection:

- A provider iterator can remain pending after the Run signal is aborted. Waiting for its next
event or asynchronous cleanup must not prevent attempt settlement.
- An unresolved browser Promise can leave a YoBrowser CDP command and its activity waiting
indefinitely unless both observe the tool cancellation signal.

Code cells already have a five-minute execution deadline. That default is useful, but a longer
operation must be able to request a longer cell lifetime without weakening user cancellation.

## Contract

### User Cancellation

- Deliver the Run signal to the provider immediately. The provider-attempt owner also interrupts
its own iterator wait if the provider ignores that signal.
- Request iterator cleanup without waiting indefinitely after cancellation. Observe late
rejections, discard late output, and record the attempt as aborted exactly once.
- Preserve usage observed before cancellation and existing no-replay rules for committed output.
- Browser tools observe cancellation while waiting and check the signal immediately before
dispatch. A canceled readiness wait must not dispatch a CDP command later.
- Cancellation ends browser activity without projecting a late response into the canceled run.
It does not claim to undo browser or external side effects already dispatched.
- Code cell startup and execution both observe cancellation. An abandoned utility process must
be reclaimed even if it becomes ready after cancellation.
- Do not add a whole-run deadline or infer failure merely from a lack of output.

### Code Cell Execution Limit

- `run_code` accepts optional `timeout_ms`, an integer duration in milliseconds. Omitting it uses
`300000` milliseconds, or five minutes. `code` and `description` remain the only required fields.
- The valid range is `1` through `2147483647`, the host timer's supported range. Invalid values fail
before a utility process or tool dispatch starts.
- The Code Mode `exec` frontend accepts the same optional field in its first-line pragma, because
both frontends share the same cell runtime.
- The limit covers one cell, including awaited subtools. It starts when the cell is attached and
does not restart on yield, `wait`, or a permission continuation.
- The tool description, parameter description, and SDK prompt all instruct the model to omit the
field for routine work and set a larger value only when the current operation is expected to
exceed five minutes. A cell override does not extend a subtool's own timeout.
- User cancellation always takes precedence over a longer requested timeout. Existing source,
output, concurrency, heartbeat, memory, startup, and abandoned-cell limits remain unchanged.

## Ownership and Scope

`DeepChatContextCoordinator` owns provider iterator cancellation and attempt provenance.
`AgentToolManager`, `YoBrowserToolHandler`, and the existing browser command path carry the tool
signal through dispatch and response handling. `ToolService` validates the public timeout input;
`RunCodeRuntimeManager` applies the selected cell deadline. Code Mode definitions and generated
SDK text expose the same contract.

This change does not modify provider retry budgets, add dependencies, introduce a new task
scheduler, change UI layout, automatically replay canceled work, or add an unlimited timeout mode.

## Acceptance Criteria

- Stopping a silent provider settles the attempt without needing another provider event or a
cooperative iterator `return()`; late output does not start a tool or create another outcome.
- A normal long-running operation is not canceled merely because it is silent.
- Stopping a pending CDP call returns control and prevents delayed dispatch after readiness.
- An omitted cell timeout still expires at five minutes; an explicit longer timeout survives that
boundary and expires at the requested duration.
- A cell with an extended timeout still stops promptly when the user cancels.
- Invalid timeout values do not dispatch work, and both model-facing frontends document the
default, omission guidance, and extension semantics.

## Implementation and Validation

- [x] Provider and browser waits observe cancellation without requiring downstream cooperation.
- [x] Utility startup observes cancellation; both frontends accept the optional cell timeout.
- [x] The maintained Code Mode contract and model-facing instructions describe the same behavior.
- [x] Regression coverage protects cancellation, late completion, and timeout overrides.
- [x] Formatting, i18n, lint, type checks, and relevant test suites pass.

Validation uses Node 24.18.0 and pnpm 10.34.5:

- `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck` pass.
- 407 main-process tests pass across 13 relevant suites: Code Mode, ToolService, browser tools,
provider attempts and retries, stream processing, Run lifecycle, and abortable waits.
- All three ChatPage stop-request tests pass, covering duplicate requests, unsuccessful stop
responses, and rejected stop requests.
- Fake-clock cases verify the five-minute default, a ten-minute override, and explicit
cancellation of a provider silent for fifteen minutes and a CDP command silent for ten minutes.
Browser integration cases use mocked Electron boundaries; no live provider credentials are used.
65 changes: 44 additions & 21 deletions src/main/agent/deepchat/loop/contextCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { MCPToolDefinition } from '@shared/types/core/mcp'
import type { ModelConfig } from '@shared/types/provider'
import type { DeepChatExecutionContract } from '@shared/types/execution-contract'
import { isDeepStrictEqual } from 'node:util'
import { awaitWithAbort } from '@/lib/awaitWithAbort'
import type {
DeepChatProviderAttemptIdentity,
DeepChatProviderAttemptOrigin,
Expand Down Expand Up @@ -559,30 +560,52 @@ async function* observeProviderAttempt(input: {
}): AsyncGenerator<LLMCoreStreamEvent, void, void> {
const { observation } = input
try {
for await (const event of input.provider.stream(input.streamInput)) {
if (!input.bypassContextBudget && input.isContextOverflowEvent(event)) {
observation.contextOverflowObserved = true
const facts = input.inspectContextOverflow?.(event)
if (facts?.matched) {
observation.contextOverflowFacts = facts
input.onContextOverflowFacts?.(facts)
const stream = input.provider.stream(input.streamInput)
let finished = false
try {
while (true) {
input.streamInput.signal.throwIfAborted()
const next = await awaitWithAbort(stream.next(), input.streamInput.signal)
input.streamInput.signal.throwIfAborted()
if (next.done) {
finished = true
break
}
}
if (event.type === 'usage') {
observation.usage = providerAttemptUsageFromEvent(event)
} else if (event.type === 'error') {
observation.errorEvent = event
observation.stopReason = 'error'
} else if (event.type === 'stop') {
observation.stopEvent = event
if (observation.stopReason !== 'error') {
observation.stopReason = event.stop_reason
const event = next.value
if (!input.bypassContextBudget && input.isContextOverflowEvent(event)) {
observation.contextOverflowObserved = true
const facts = input.inspectContextOverflow?.(event)
if (facts?.matched) {
observation.contextOverflowFacts = facts
input.onContextOverflowFacts?.(facts)
}
}
if (event.type === 'usage') {
observation.usage = providerAttemptUsageFromEvent(event)
} else if (event.type === 'error') {
observation.errorEvent = event
observation.stopReason = 'error'
} else if (event.type === 'stop') {
observation.stopEvent = event
if (observation.stopReason !== 'error') {
observation.stopReason = event.stop_reason
}
}
}

if (!isProviderControlEvent(event)) {
observation.outputCommitted = true
yield event
if (!isProviderControlEvent(event)) {
observation.outputCommitted = true
yield event
}
}
} finally {
if (!finished) {
// An async generator's return() can wait for its outstanding next() forever.
const closing = stream.return(undefined)
if (input.streamInput.signal.aborted) {
void closing.catch(() => undefined)
} else {
await awaitWithAbort(closing, input.streamInput.signal)
}
}
}
} catch (error) {
Expand Down
59 changes: 44 additions & 15 deletions src/main/desktop/browser/BrowserTab.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { WebContents } from 'electron'
import { nanoid } from 'nanoid'
import { awaitWithAbort } from '@/lib/awaitWithAbort'
import {
BrowserPageStatus,
type BrowserPageInfo,
Expand Down Expand Up @@ -72,10 +73,13 @@ export class BrowserTab {
url: string,
timeoutMs: number = 30000,
beforeDispatch?: () => void,
onDispatched?: () => void
onDispatched?: () => void,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted()
this.ensureAvailable()
beforeDispatch?.()
signal?.throwIfAborted()
this.beginMainFrameNavigation(url)

const loadPromise = this.webContents.loadURL(url)
Expand All @@ -90,13 +94,15 @@ export class BrowserTab {
onDispatched?.()

try {
await Promise.race([this.waitForInteractiveReady(timeoutMs), loadPromise])
await Promise.race([this.waitForInteractiveReady(timeoutMs, signal), loadPromise])
signal?.throwIfAborted()
if (!this.interactiveReady) {
throw new Error(`Navigation finished before dom-ready for ${url}`)
}
this.title = this.webContents.getTitle() || url
this.updatedAt = Date.now()
} catch (error) {
signal?.throwIfAborted()
this.markNavigationError(error)
throw error
}
Expand All @@ -118,19 +124,24 @@ export class BrowserTab {
method: string,
params?: Record<string, unknown>,
beforeDispatch?: () => void,
onDispatched?: () => void
onDispatched?: () => void,
signal?: AbortSignal
): Promise<unknown> {
signal?.throwIfAborted()
if (NAVIGATION_CDP_METHODS.has(method)) {
this.ensureAvailable()
} else {
await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`)
await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`, undefined, signal)
}

const session = await this.ensureSession()
const session = await awaitWithAbort(this.ensureSession(), signal)
signal?.throwIfAborted()
beforeDispatch?.()
signal?.throwIfAborted()
const responsePromise = session.sendCommand(method, params ?? {})
onDispatched?.()
const response = await responsePromise
const response = await awaitWithAbort(responsePromise, signal)
signal?.throwIfAborted()

if (method === 'Page.navigate') {
const navigationResponse = response as {
Expand Down Expand Up @@ -558,8 +569,10 @@ export class BrowserTab {

private async ensureInteractiveReadyOrWait(
action: string,
timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS
timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS,
signal?: AbortSignal
): Promise<void> {
signal?.throwIfAborted()
this.ensureAvailable()

if (this.interactiveReady) {
Expand All @@ -568,7 +581,7 @@ export class BrowserTab {

if (this.awaitingMainFrameInteractive || this.status === BrowserPageStatus.Loading) {
try {
await this.waitForInteractiveReady(timeoutMs)
await this.waitForInteractiveReady(timeoutMs, signal)
} catch (error) {
if (!this.isInteractiveReadyTimeoutError(error)) {
throw error
Expand All @@ -579,7 +592,7 @@ export class BrowserTab {
return
}

if (await this.probeInteractiveReadiness()) {
if (await this.probeInteractiveReadiness(signal)) {
return
}
}
Expand Down Expand Up @@ -790,7 +803,8 @@ export class BrowserTab {
})
}

private async waitForInteractiveReady(timeoutMs: number): Promise<void> {
private async waitForInteractiveReady(timeoutMs: number, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted()
if (this.interactiveReady) {
return
}
Expand All @@ -808,6 +822,7 @@ export class BrowserTab {
this.webContents.removeListener('dom-ready', onDomReady)
this.webContents.removeListener('did-fail-load', onFailLoad as any)
this.webContents.removeListener('destroyed', onDestroyed)
signal?.removeEventListener('abort', onAbort)
}

const onDomReady = () => {
Expand All @@ -834,6 +849,11 @@ export class BrowserTab {
reject(new Error('Page was destroyed before dom-ready'))
}

const onAbort = () => {
cleanup()
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
}

timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`${INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX} ${this.url}`))
Expand All @@ -842,6 +862,8 @@ export class BrowserTab {
this.webContents.once('dom-ready', onDomReady)
this.webContents.on('did-fail-load', onFailLoad as any)
this.webContents.once('destroyed', onDestroyed)
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) onAbort()
})
}

Expand All @@ -851,12 +873,15 @@ export class BrowserTab {
)
}

private async probeInteractiveReadiness(): Promise<boolean> {
private async probeInteractiveReadiness(signal?: AbortSignal): Promise<boolean> {
try {
const session = await this.ensureSession()
const probe = (await this.cdpManager.evaluateScript(
session,
`(() => {
signal?.throwIfAborted()
const session = await awaitWithAbort(this.ensureSession(), signal)
signal?.throwIfAborted()
const probe = (await awaitWithAbort(
this.cdpManager.evaluateScript(
session,
`(() => {
try {
return {
readyState: document.readyState,
Expand All @@ -867,7 +892,10 @@ export class BrowserTab {
return null
}
})()`
),
signal
)) as { readyState?: unknown; hasBody?: unknown; href?: unknown } | null
signal?.throwIfAborted()

const readyState = typeof probe?.readyState === 'string' ? probe.readyState : ''
const hasBody = probe?.hasBody === true
Expand All @@ -888,6 +916,7 @@ export class BrowserTab {
}
return true
} catch {
signal?.throwIfAborted()
return false
}
}
Expand Down
Loading