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
12 changes: 8 additions & 4 deletions docs/architecture/local-control-plane/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Main atomically replaces a descriptor with this public shape:
```ts
type LocalControlDescriptorV1 = {
protocolVersion: 1
surfaceVersion: 1
surfaceVersion: 2
appVersion: string
endpoint: { kind: 'unix'; path: string } | { kind: 'pipe'; name: string }
pid: number
Expand Down Expand Up @@ -215,7 +215,7 @@ expose those temporary paths.

## Contract Ownership and Surface

`CLI_SURFACE_V1` is a readonly registry whose entries reference the same `RouteContract` objects used
`CLI_SURFACE_V2` is a readonly registry whose entries reference the same `RouteContract` objects used
Comment thread
coderabbitai[bot] marked this conversation as resolved.
by renderer IPC. A surface entry adds only transport and policy metadata:

```ts
Expand All @@ -241,7 +241,7 @@ Public method names describe their domain (`models.invoke`, `providers.listPubli
`sessions.runDetached`). The `cli.*` namespace is reserved for behavior that exists only to operate
or diagnose the bundled CLI.

### V1 Capability Matrix
### V2 Capability Matrix

`H` means an authenticated human CLI connection. `A` means a short-lived Agent connection with the
listed scope. “Policy” means the renderer-only effect policy may be required; it never means a CLI
Expand Down Expand Up @@ -540,7 +540,11 @@ existing lifecycle, then starts the initial turn. It returns a durable run/sessi
streaming. Disconnect does not destroy a detached run; status/messages can be recovered from session
state and event cursors. If initial-turn startup fails, the response and run event retain that durable
identity but expose only a stable failure message; upstream error text is excluded from public output
and logs. `runs.cancel` is idempotent and ownership checked.
and logs. `runs.get` projects `running`, `awaiting_interaction`, or `terminal` separately from the
Session status so a valid permission or question pause remains observable. `run watch` treats
provider-round `chat.stream.completed` and `chat.stream.failed` events as progress only; it exits
after the root Session reaches `idle` or `error`, never when a descendant Session or one provider
round finishes. `runs.cancel` is idempotent and ownership checked.

`events.subscribe` is human-only in V1. An Agent can own only its currently executing conversation,
so waiting on that run from its bash tool would deadlock the run on itself. Agent callers may use the
Expand Down
6 changes: 4 additions & 2 deletions docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ deepchat run cancel --run <run-id> --json
```

`agent run` 先创建 durable detached Session,再启动首轮。CLI 断开不会删除 run;可以通过
`run get` 恢复消息,human caller 可通过 cursor 续接 `run watch`。Agent caller 自身不能递归执行
`run get` 恢复消息和 `running | awaiting_interaction | terminal` phase,human caller 可通过 cursor
续接 `run watch`。一次 provider stream 完成或失败不会提前结束 watch;只有 root Session 进入
`idle`/`error` 才是整个 detached run 的终态。Agent caller 自身不能递归执行
`agent run`,也不能等待当前正在执行的自身 run;Agent 仅可使用非阻塞的 `run get` 与幂等的
`run cancel`。

Expand Down Expand Up @@ -218,7 +220,7 @@ Agent 调用额外经过以下控制:

1. shell command permission;
2. main 签发的短期 scoped token;
3. deny-by-default `CLI_SURFACE_V1` caller/scope policy;
3. deny-by-default `CLI_SURFACE_V2` caller/scope policy;
4. effect policy 与 renderer-only approval;
5. ownership、rate、call/byte quota 与脱敏审计。

Expand Down
3 changes: 2 additions & 1 deletion src/cli/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ export function formatHumanResult(
}
case 'runs.get': {
const result = contract.output.parse(value)
const interaction = result.phase === 'awaiting_interaction' ? ', awaiting interaction' : ''
return [
`Run ${result.runId} (${result.status})`,
`Run ${result.runId} (${result.status}${interaction})`,
`Agent: ${result.agentId}`,
`Model: ${result.providerId}/${result.modelId}`,
...result.messages.flatMap((message) => [
Expand Down
63 changes: 53 additions & 10 deletions src/main/agent/deepchat/runtime/sessionUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,69 @@ export const buildAssistantDeliverySegments = (
blocks: AssistantMessageBlock[]
): AssistantDeliverySegment[] => buildDeliverySegments(messageId, blocks)

function isWaitingInteractionBlock(block: AssistantMessageBlock): boolean {
return (
block.type === 'action' &&
block.status === 'pending' &&
block.extra?.needsUserAction !== false &&
Boolean(block.tool_call?.id) &&
(block.action_type === 'tool_call_permission' || block.action_type === 'question_request')
)
}

function hasSubagentWaitingInteraction(block: AssistantMessageBlock): boolean {
if (block.type !== 'tool_call' || block.tool_call?.name !== 'subagent_orchestrator') return false
const rawProgress = block.extra?.subagentProgress
if (typeof rawProgress !== 'string' || !rawProgress.trim()) return false
try {
const progress = JSON.parse(rawProgress) as { tasks?: unknown }
if (!Array.isArray(progress?.tasks)) return false
return progress.tasks.some((task) => {
if (!task || typeof task !== 'object' || Array.isArray(task)) return false
const candidate = task as { sessionId?: unknown; waitingInteraction?: unknown }
if (typeof candidate.sessionId !== 'string' || !candidate.sessionId) return false
const waiting = candidate.waitingInteraction
if (!waiting || typeof waiting !== 'object' || Array.isArray(waiting)) return false
const interaction = waiting as {
type?: unknown
messageId?: unknown
toolCallId?: unknown
actionBlock?: unknown
}
return (
(interaction.type === 'permission' || interaction.type === 'question') &&
typeof interaction.messageId === 'string' &&
Boolean(interaction.messageId) &&
typeof interaction.toolCallId === 'string' &&
Boolean(interaction.toolCallId) &&
Boolean(interaction.actionBlock) &&
typeof interaction.actionBlock === 'object' &&
!Array.isArray(interaction.actionBlock)
)
})
} catch {
return false
}
}

export const hasWaitingInteraction = (blocks: AssistantMessageBlock[]): boolean =>
blocks.some((block) => isWaitingInteractionBlock(block) || hasSubagentWaitingInteraction(block))

export const extractWaitingInteraction = (
blocks: AssistantMessageBlock[],
messageId: string
): DeepChatInternalSessionWaitingInteraction | null => {
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]
if (
block.type !== 'action' ||
block.status !== 'pending' ||
block.extra?.needsUserAction !== true ||
!block.tool_call?.id
) {
continue
}
if (!isWaitingInteractionBlock(block)) continue
const toolCallId = block.tool_call?.id
if (!toolCallId) continue

if (block.action_type === 'tool_call_permission') {
return {
type: 'permission',
messageId,
toolCallId: block.tool_call.id,
toolCallId,
actionBlock: JSON.parse(JSON.stringify(block)) as AssistantMessageBlock
}
}
Expand All @@ -101,7 +144,7 @@ export const extractWaitingInteraction = (
return {
type: 'question',
messageId,
toolCallId: block.tool_call.id,
toolCallId,
actionBlock: JSON.parse(JSON.stringify(block)) as AssistantMessageBlock
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,16 @@ export async function createMainProcessControl(dependencies: {
turn: sessionTurn,
projection: sessionQuery,
sessions: appSessionService,
getPendingAssistantMessages: (runId) =>
sessionData.transcript.getPendingAssistantMessages(runId),
hasWaitingDescendantInteraction: (runId) =>
liveDelegationRepository
.listActiveTurns()
.some(
({ delegation, turn }) =>
(turn.status === 'waiting_permission' || turn.status === 'waiting_question') &&
resolveSessionRunId(delegation.parentSessionId) === runId
),
eventHub: typedEventHub,
log: logger
})
Expand Down
2 changes: 1 addition & 1 deletion src/main/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export {
createCliProviderModelAdminRoutes,
type CliProviderModelAdminDependencies
} from './providerModelAdminRoutes'
export { CLI_SURFACE_V1, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface'
export { CLI_SURFACE_V2, getCliSurfaceEntry, listCliSurfaceCapabilities } from './surface'
export {
CliMutationGuard,
type CliApprovalPresentationPort,
Expand Down
2 changes: 1 addition & 1 deletion src/main/cli/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function createCliRoutes(deps: {
{
id: 'surface' as const,
status: capabilities.length > 0 ? ('ok' as const) : ('error' as const),
message: `${capabilities.length} V1 methods are registered`
message: `${capabilities.length} surface V${LOCAL_CONTROL_SURFACE_VERSION} methods are registered`
},
{
id: 'renderer' as const,
Expand Down
34 changes: 33 additions & 1 deletion src/main/cli/runService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
runsTurnFailedEvent
} from '@shared/contracts/events'
import { extractUserMessageInput } from '@/session/data/userMessageContent'
import { hasWaitingInteraction } from '@/agent/deepchat/runtime/sessionUpdates'
import { projectFinalAssistantAnswer } from '@shared/lib/assistantDeliverySegments'
import type { AssistantMessageBlock } from '@shared/types/agent-interface'
import {
Expand Down Expand Up @@ -76,6 +77,8 @@ export type CliRunServiceOptions = Readonly<{
turn: RunTurnPort
projection: RunProjectionPort
sessions: RunSessionStorePort
getPendingAssistantMessages(runId: string): ChatMessageRecord[]
hasWaitingDescendantInteraction(runId: string): boolean
eventHub: TypedEventHub
now?: () => number
log?: Pick<Console, 'warn'>
Expand Down Expand Up @@ -119,6 +122,27 @@ function messageText(message: ChatMessageRecord): string {
}
}

function runPhase(
status: SessionWithState['status'],
messages: readonly ChatMessageRecord[],
hasWaitingDescendantInteraction: boolean
): PublicRunSnapshot['phase'] {
if (status !== 'generating') return 'terminal'
if (hasWaitingDescendantInteraction) return 'awaiting_interaction'
for (const message of messages) {
if (message.role !== 'assistant') continue
try {
const blocks = JSON.parse(message.content) as AssistantMessageBlock[]
if (Array.isArray(blocks) && hasWaitingInteraction(blocks)) {
return 'awaiting_interaction'
}
} catch {
// A malformed transcript cannot manufacture an interaction wait.
}
}
return 'running'
}

function toPublicMessage(message: ChatMessageRecord): PublicRunMessage {
const text = truncateUtf8(messageText(message), RUN_MESSAGE_MAX_TEXT_BYTES)
return {
Expand Down Expand Up @@ -420,7 +444,6 @@ export class CliRunService {
const payload = data as { runId?: unknown; sessionId?: unknown; status?: unknown }
if (event === runsTurnFailedEvent.name) return payload.runId === runId
if (payload.sessionId !== runId) return false
if (event === 'chat.stream.completed' || event === 'chat.stream.failed') return true
if (event !== 'sessions.status.changed') return false
const status = payload.status
return status === 'idle' || status === 'error'
Expand Down Expand Up @@ -458,13 +481,22 @@ export class CliRunService {
this.requireRunSnapshot(runId),
this.options.projection.listMessagesPage(runId, { limit, cursor: cursor ?? null })
])
const phaseMessages =
session.status === 'generating' && (cursor != null || page.hasMore)
? this.options.getPendingAssistantMessages(runId)
: page.messages
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const projectedPage = projectMessagePage(page)
return runsGetRoute.output.parse({
runId,
sessionId: session.id,
agentId: session.agentId,
title: session.title,
status: session.status,
phase: runPhase(
session.status,
phaseMessages,
session.status === 'generating' && this.options.hasWaitingDescendantInteraction(runId)
),
providerId: session.providerId,
modelId: session.modelId,
createdAt: session.createdAt,
Expand Down
8 changes: 4 additions & 4 deletions src/main/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
type CliControlLayout
} from './descriptor'
import { CliRequestError } from './errors'
import { CLI_SURFACE_V1 } from './surface'
import { CLI_SURFACE_V2 } from './surface'
import type { CliSurfaceEntry } from './surface'
import type { CliRuntimeStatus } from './routes'
import type { ArtifactSpool } from './artifactSpool'
Expand Down Expand Up @@ -287,7 +287,7 @@ export class CliServer {
this.platform = dependencies.platform ?? process.platform
this.pid = dependencies.pid ?? process.pid
this.log = dependencies.log ?? console
this.surface = new Map(dependencies.surface ?? CLI_SURFACE_V1)
this.surface = new Map(dependencies.surface ?? CLI_SURFACE_V2)
}

getStatus(): CliRuntimeStatus {
Expand Down Expand Up @@ -658,7 +658,7 @@ export class CliServer {
routeMethod = declaredMethod
entry = this.surface.get(declaredMethod)
if (!entry || entry.transport !== requestTransport) {
throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', {
throw new CliRequestError('not_found', 'Method is not exposed by the CLI surface', {
httpStatus: 404
})
}
Expand Down Expand Up @@ -711,7 +711,7 @@ export class CliServer {
}
entry ??= this.surface.get(rpcRequest.method)
if (!entry || entry.transport !== requestTransport) {
throw new CliRequestError('not_found', 'Method is not exposed by CLI surface V1', {
throw new CliRequestError('not_found', 'Method is not exposed by the CLI surface', {
httpStatus: 404
})
}
Expand Down
8 changes: 4 additions & 4 deletions src/main/cli/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ const mediaEntry = (contract: RouteContract): CliSurfaceEntry => ({
}
})

const CLI_SURFACE_V1_ENTRIES = [
const CLI_SURFACE_V2_ENTRIES = [
{
contract: modelsInvokeRoute,
effect: 'compute',
Expand Down Expand Up @@ -1002,14 +1002,14 @@ function createSurfaceRegistry(
return registry
}

export const CLI_SURFACE_V1 = createSurfaceRegistry(CLI_SURFACE_V1_ENTRIES)
export const CLI_SURFACE_V2 = createSurfaceRegistry(CLI_SURFACE_V2_ENTRIES)

export function getCliSurfaceEntry(method: string): CliSurfaceEntry | undefined {
return CLI_SURFACE_V1.get(method)
return CLI_SURFACE_V2.get(method)
}

export function listCliSurfaceCapabilities(): CliCapability[] {
return Array.from(CLI_SURFACE_V1.values(), (entry) => ({
return Array.from(CLI_SURFACE_V2.values(), (entry) => ({
method: entry.contract.name,
possibleEffects: [...listCliSurfaceEffects(entry)],
callers: [...entry.callers],
Expand Down
10 changes: 10 additions & 0 deletions src/main/session/data/tables/deepchatMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ export class DeepChatMessagesTable extends BaseTable {
.all(sessionId) as DeepChatMessageRow[]
}

getPendingAssistantBySession(sessionId: string): DeepChatMessageRow[] {
return this.db
.prepare(
`SELECT * FROM deepchat_messages
WHERE session_id = ? AND role = 'assistant' AND status = 'pending'
ORDER BY order_seq, id`
)
.all(sessionId) as DeepChatMessageRow[]
}

hasBySession(sessionId: string): boolean {
return Boolean(
this.db.prepare('SELECT 1 FROM deepchat_messages WHERE session_id = ? LIMIT 1').get(sessionId)
Expand Down
5 changes: 5 additions & 0 deletions src/main/session/data/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,11 @@ export class SessionTranscript {
return this.toRecords(rows)
}

getPendingAssistantMessages(sessionId: string): ChatMessageRecord[] {
const rows = this.database.deepchatMessagesTable.getPendingAssistantBySession(sessionId)
return this.toRecords(rows)
}

hasMessages(sessionId: string): boolean {
return this.database.deepchatMessagesTable.hasBySession(sessionId)
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/contracts/localControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod'
import { JsonValueSchema, TimestampMsSchema, type JsonValue } from './json'

export const LOCAL_CONTROL_PROTOCOL_VERSION = 1 as const
export const LOCAL_CONTROL_SURFACE_VERSION = 1 as const
export const LOCAL_CONTROL_SURFACE_VERSION = 2 as const
export const LOCAL_CONTROL_MAX_REQUEST_TIMEOUT_MS = 30 * 60_000
export const LOCAL_CONTROL_MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024
export const LOCAL_CONTROL_MAX_STREAM_RECORD_BYTES = 20 * 1024 * 1024
Expand Down
5 changes: 3 additions & 2 deletions src/shared/contracts/routes/ocr.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { defineRouteContract } from '../common'
import { LOCAL_CONTROL_PROTOCOL_VERSION, LOCAL_CONTROL_SURFACE_VERSION } from '../localControl'
import {
ATTACHMENT_OCR_MAX_TEXT_CHARACTERS,
ATTACHMENT_PDF_OCR_MAX_PAGE_SPANS,
Expand Down Expand Up @@ -138,8 +139,8 @@ export const OcrBenchmarkSchema = z
inputBytes: z.number().int().positive().max(OCR_EXTRACTION_MAX_INPUT_BYTES),
durationMs: OcrPublicTimingSchema,
appVersion: z.string().min(1).max(128),
protocolVersion: z.literal(1),
surfaceVersion: z.literal(1)
protocolVersion: z.literal(LOCAL_CONTROL_PROTOCOL_VERSION),
surfaceVersion: z.literal(LOCAL_CONTROL_SURFACE_VERSION)
})
.strict()
.superRefine((benchmark, context) => {
Expand Down
Loading