@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-inner.js +7 -1
- package/cordis.gotry-patch.yml +2 -0
- package/dist/capabilities/artifacts.js +41 -4
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
- package/dist/scripts/bootstrap-tests.js +5 -0
- package/dist/scripts/issue-271-liveness-tests.js +6 -0
- package/dist/scripts/turn-handoff-collect-tests.js +7 -1
- package/dist/scripts/turn-handoff-collect.js +68 -20
- package/dist/scripts/turn-handoff-job-tests.js +222 -0
- package/dist/scripts/turn-handoff-web-e2e.js +240 -0
- package/dist/src/index.js +24 -8
- package/dist/src/turn-deadline.js +113 -16
- package/dist/src/turn-handoff-job.js +124 -0
- package/extension/manifest.json +2 -2
- package/package.json +2 -1
- package/ts/capabilities/artifacts.ts +40 -5
- package/ts/scripts/turn-handoff-collect.ts +63 -21
- package/ts/src/index.ts +24 -8
- package/ts/src/turn-deadline.ts +111 -18
- package/ts/src/turn-handoff-job.ts +120 -0
package/ts/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain
|
|
|
65
65
|
import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.ts'
|
|
66
66
|
import { gateArtifact, type AirlineAirportMap } from './artifact-gate.ts'
|
|
67
67
|
import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.ts'
|
|
68
|
+
import { startTurnHandoffJob } from './turn-handoff-job.ts'
|
|
68
69
|
import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.ts'
|
|
69
70
|
import { routingAdvice, renderRoutingCard, toolRoutingHeadline, persistedDownChannels, type ChannelIntent } from '../capabilities/channel-registry.ts'
|
|
70
71
|
import { registerBenchmarkEnvironmentBridge, type BenchmarkSubprocessService } from './benchmark-environment-bridge.ts'
|
|
@@ -524,7 +525,11 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
524
525
|
? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
|
|
525
526
|
: rawBenchmarkEnvironmentConfigPath.trim()
|
|
526
527
|
? { fixedPolicy: benchmarkPinnedPolicy() }
|
|
527
|
-
: { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.'
|
|
528
|
+
: { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.',
|
|
529
|
+
// A one-shot headless process exits after its final response and cannot
|
|
530
|
+
// own ongoing work. Keep that ticket queued for explicit collection.
|
|
531
|
+
startHandoff: (ticket, agent, root) => typeof ctx.get === 'function' && ctx.get('connection')
|
|
532
|
+
? startTurnHandoffJob(ctx, ticket, agent, root) : Promise.resolve(ticket) })
|
|
528
533
|
if (rawBenchmarkEnvironmentConfigPath.trim()) {
|
|
529
534
|
// Benchmark mode is a deliberately minimal kernel: only the model
|
|
530
535
|
// override and the environment bridge are installed besides the pinned
|
|
@@ -1104,8 +1109,8 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1104
1109
|
name: 'gotry_turn_handoff_list',
|
|
1105
1110
|
description:
|
|
1106
1111
|
'List background deep-planning handoff tickets (read-only). Call this whenever the user asks about '
|
|
1107
|
-
+ 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open =
|
|
1108
|
-
+ '
|
|
1112
|
+
+ 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = queued, worker not started; '
|
|
1113
|
+
+ 'running = started with a native job id (use job_list for live status); settled = deliverable ready (excerpt included, full text in the '
|
|
1109
1114
|
+ 'ticket\'s .deliverable.md), failed = honest failure note. Never fabricate a deliverable that is not here.',
|
|
1110
1115
|
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层(同 session 刀法)
|
|
1111
1116
|
parameters: {
|
|
@@ -1115,9 +1120,10 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1115
1120
|
schema: { type: 'json' },
|
|
1116
1121
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
1117
1122
|
},
|
|
1118
|
-
async execute(args,
|
|
1123
|
+
async execute(args, exec: unknown) {
|
|
1119
1124
|
const q = interpretArgs<{ ticketId?: string }>(args)
|
|
1120
|
-
const
|
|
1125
|
+
const configuredRoot = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.'
|
|
1126
|
+
const root = configuredRoot === '.' ? sessionCwd(exec) ?? '.' : configuredRoot
|
|
1121
1127
|
const all = await listTurnHandoffTickets(root)
|
|
1122
1128
|
const tickets = (q.ticketId ? all.filter(t => t.id === q.ticketId) : all).slice(0, 10)
|
|
1123
1129
|
if (tickets.length === 0) {
|
|
@@ -1130,9 +1136,18 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1130
1136
|
}
|
|
1131
1137
|
const lines = tickets.map(t => {
|
|
1132
1138
|
const head = `[${t.status}] ${t.id} ${t.objective.slice(0, 40)}`
|
|
1133
|
-
if (t.status === 'open') return `${head}
|
|
1139
|
+
if (t.status === 'open') return `${head}——待启动,后台尚未运行`
|
|
1140
|
+
if (t.status === 'running') {
|
|
1141
|
+
const owner = (exec as { agent?: import('@deepseek-ai/dsh-agent').Agent }).agent
|
|
1142
|
+
// Native counters restart with the host. A recycled id alone is not
|
|
1143
|
+
// the durable ticket's identity and must not offer another trip's job.
|
|
1144
|
+
const live = owner && ctx.get('jobs')?.list(owner.id).some(job => job.id === t.jobId
|
|
1145
|
+
&& job.owner === owner.id && job.kind === 'gotry-planning' && job.label.startsWith(`${t.id}: `)
|
|
1146
|
+
&& (job.status === 'running' || job.status === 'stopping'))
|
|
1147
|
+
return `${head}——${live ? `后台任务 ${t.jobId},预计 ${t.etaLabel}` : '运行记录已中断,当前没有活跃后台任务'} `
|
|
1148
|
+
}
|
|
1134
1149
|
if (t.status === 'failed') return `${head}——失败:${t.error ?? '未知原因'}(请重新发起规划)`
|
|
1135
|
-
return `${head}
|
|
1150
|
+
return `${head}——${t.deliverablePath ? `已交付:${t.deliverablePath}` : '交付文件缺失'},摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`
|
|
1136
1151
|
})
|
|
1137
1152
|
return {
|
|
1138
1153
|
ok: true, count: tickets.length, tickets,
|
|
@@ -2490,6 +2505,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
2490
2505
|
try {
|
|
2491
2506
|
r = await listArtifacts({
|
|
2492
2507
|
stateRoot: config.stateRoot ?? '.',
|
|
2508
|
+
handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot),
|
|
2493
2509
|
cwd: sessionCwd(exec),
|
|
2494
2510
|
limit: q.limit,
|
|
2495
2511
|
offset: q.offset,
|
|
@@ -2582,7 +2598,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
2582
2598
|
const q = args
|
|
2583
2599
|
if (!q.path) throw new Error('gotry_artifacts_read requires path')
|
|
2584
2600
|
if (!q.path) return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' })) as Record<string, never>
|
|
2585
|
-
const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit })
|
|
2601
|
+
const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', handoffRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? ((config.stateRoot ?? '.') === '.' ? sessionCwd(exec) ?? '.' : config.stateRoot), cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit })
|
|
2586
2602
|
if (!r.ok) return JSON.parse(JSON.stringify(r)) as Record<string, never>
|
|
2587
2603
|
// 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的
|
|
2588
2604
|
// path/offset/lines/totalLines/lang;fallback content 用 source label 前缀,
|
package/ts/src/turn-deadline.ts
CHANGED
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
* `gotry_turn_handoff.v1` 工单并指令模型告知用户 ETA 与回访方式。
|
|
14
14
|
* - handoff 工单是**新的独立格式**,落在 `<stateRoot>/gotry-state/
|
|
15
15
|
* turn-handoffs/`,不与 `async/` 的 loop 求解工单混用——async-collect
|
|
16
|
-
* 对无 spec 的 state 会立即结算 failed(假失败)
|
|
17
|
-
*
|
|
16
|
+
* 对无 spec 的 state 会立即结算 failed(假失败)。产品通过原生 jobs
|
|
17
|
+
* 启动独立收集器;仅落单不代表后台已开始。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { randomUUID } from 'node:crypto'
|
|
21
21
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
22
|
-
import { join } from 'node:path'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
23
|
import type { Context } from '@deepseek-ai/cordis'
|
|
24
24
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
25
25
|
// 类型面显式依赖:session/event、session/disposed 的事件表声明在 dsh-session 的
|
|
26
26
|
// cordis Events augmentation 里——不显式导入时,该声明只在 peer 恰好物化进
|
|
27
27
|
// ts/node_modules 的机器上可见(legacy-peer-deps 安装则缺,5 个 TS2345/TS7006)。
|
|
28
|
-
import type {} from '@deepseek-ai/dsh-session'
|
|
28
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
29
29
|
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
|
30
30
|
import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor, type TurnPolicy } from './turn-policy.ts'
|
|
31
31
|
|
|
@@ -41,7 +41,11 @@ export interface TurnHandoffTicket {
|
|
|
41
41
|
userMessage: string
|
|
42
42
|
requestedAt: string
|
|
43
43
|
etaLabel: string
|
|
44
|
-
status: 'open' | 'settled' | 'failed'
|
|
44
|
+
status: 'open' | 'running' | 'settled' | 'failed'
|
|
45
|
+
jobId?: string
|
|
46
|
+
workspaceCwd?: string
|
|
47
|
+
ownerSessionId?: string
|
|
48
|
+
context?: string
|
|
45
49
|
/** 结算时间(ISO);open 时缺省。 */
|
|
46
50
|
settledAt?: string
|
|
47
51
|
/** 结算交付物文件名(与工单同目录的 .deliverable.md)。 */
|
|
@@ -57,6 +61,8 @@ export interface TurnHandoffTicketView {
|
|
|
57
61
|
objective: string
|
|
58
62
|
requestedAt: string
|
|
59
63
|
etaLabel: string
|
|
64
|
+
jobId?: string
|
|
65
|
+
deliverablePath?: string
|
|
60
66
|
settledAt?: string
|
|
61
67
|
error?: string
|
|
62
68
|
deliverableExcerpt?: string
|
|
@@ -71,9 +77,37 @@ export interface TurnDeadlineOptions {
|
|
|
71
77
|
stateRoot?: string
|
|
72
78
|
/** Wall-clock source — overridden by tests for determinism. */
|
|
73
79
|
now?: () => number
|
|
80
|
+
/** Product host owns dispatch and native job tracking. Absent means queued. */
|
|
81
|
+
startHandoff?: (ticket: TurnHandoffTicket, agent: Agent, stateRoot: string) => Promise<TurnHandoffTicket>
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
function handoffContext(events: readonly SessionEvent[]): string | undefined {
|
|
85
|
+
const questionIds = new Set(events.filter(event => event.type === 'tool/call' && event.data.name === 'ask_user_question')
|
|
86
|
+
.map(event => event.type === 'tool/call' ? event.data.callId : ''))
|
|
87
|
+
// Human constraints are lossless. Refuse dispatch if they exceed the input
|
|
88
|
+
// budget; silently cutting an accessibility/budget answer changes the task.
|
|
89
|
+
const answers = events.filter(event => (event.type === 'user/message' && event.data.source.kind === 'user')
|
|
90
|
+
|| (event.type === 'tool/call' && questionIds.has(event.data.callId))
|
|
91
|
+
|| (event.type === 'tool/result' && event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId)))
|
|
92
|
+
.map(event => JSON.stringify(event.data)).join('\n')
|
|
93
|
+
if (answers.length > 64_000) throw new Error('Prior user answers exceed the handoff context limit; planning paused without dropping constraints. Start a new session with the complete trip constraints.')
|
|
94
|
+
let remaining = 18_000
|
|
95
|
+
const evidence: string[] = []
|
|
96
|
+
for (const event of [...events].reverse()) {
|
|
97
|
+
if (event.type !== 'tool/result' || (event.data.message.source.kind === 'tool' && questionIds.has(event.data.message.source.callId))) continue
|
|
98
|
+
const row = JSON.stringify(event.data)
|
|
99
|
+
// Evidence may be excerpted, with an explicit marker and valid JSON.
|
|
100
|
+
const excerpt = row.length > remaining
|
|
101
|
+
? JSON.stringify({ truncated: true, excerpt: row.slice(-Math.floor(remaining / 2)) }) : row
|
|
102
|
+
if (excerpt.length + 1 > remaining) break
|
|
103
|
+
evidence.unshift(excerpt)
|
|
104
|
+
remaining -= excerpt.length + 1
|
|
105
|
+
if (remaining < 100) break
|
|
106
|
+
}
|
|
107
|
+
return answers || evidence.length ? `${answers}\n${evidence.join('\n')}` : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeTurnHandoffTicket(stateRoot: string, userMessage: string, details: Pick<TurnHandoffTicket, 'workspaceCwd' | 'ownerSessionId' | 'context'> = {}): Promise<TurnHandoffTicket> {
|
|
77
111
|
const ticket: TurnHandoffTicket = {
|
|
78
112
|
schema: TURN_HANDOFF_SCHEMA,
|
|
79
113
|
id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
|
|
@@ -82,6 +116,7 @@ export async function writeTurnHandoffTicket(stateRoot: string, userMessage: str
|
|
|
82
116
|
requestedAt: new Date().toISOString(),
|
|
83
117
|
etaLabel: TURN_HANDOFF_ETA_LABEL,
|
|
84
118
|
status: 'open',
|
|
119
|
+
...details,
|
|
85
120
|
}
|
|
86
121
|
await writeTicketJson(stateRoot, ticket)
|
|
87
122
|
return ticket
|
|
@@ -92,7 +127,7 @@ function handoffDir(stateRoot: string): string {
|
|
|
92
127
|
return join(root, 'gotry-state', 'turn-handoffs')
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
|
|
130
|
+
export async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
|
|
96
131
|
const dir = handoffDir(stateRoot)
|
|
97
132
|
await mkdir(dir, { recursive: true })
|
|
98
133
|
const path = join(dir, `${ticket.id}.json`)
|
|
@@ -151,12 +186,14 @@ export async function listTurnHandoffTickets(stateRoot: string): Promise<TurnHan
|
|
|
151
186
|
objective: raw.objective,
|
|
152
187
|
requestedAt: raw.requestedAt,
|
|
153
188
|
etaLabel: raw.etaLabel,
|
|
189
|
+
...(raw.jobId ? { jobId: raw.jobId } : {}),
|
|
154
190
|
...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
|
|
155
191
|
...(raw.error ? { error: raw.error } : {}),
|
|
156
192
|
}
|
|
157
|
-
if (raw.status
|
|
193
|
+
if ((raw.status === 'settled' || raw.status === 'failed') && raw.deliverableFile === `${raw.id}.deliverable.md`) {
|
|
158
194
|
try {
|
|
159
195
|
const text = await readFile(join(dir, raw.deliverableFile), 'utf-8')
|
|
196
|
+
view.deliverablePath = resolve(dir, raw.deliverableFile)
|
|
160
197
|
view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text
|
|
161
198
|
} catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
|
|
162
199
|
}
|
|
@@ -200,7 +237,12 @@ function exhaustionResult(reason: string): ToolExecutionResult {
|
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
function handoffResult(ticket: TurnHandoffTicket): ToolExecutionResult {
|
|
203
|
-
const
|
|
240
|
+
const state = ticket.status === 'running' && ticket.jobId
|
|
241
|
+
? `Background planning started as native background job ${ticket.jobId} (estimated ${ticket.etaLabel}; not a guarantee). Track or cancel with job_list/job_output/job_kill.`
|
|
242
|
+
: ticket.status === 'failed'
|
|
243
|
+
? `Background planning could not start: ${ticket.error ?? 'unknown failure'}. Tell the user it failed; do not promise later delivery.`
|
|
244
|
+
: 'The ticket is queued; the background worker has not started. Tell the user it is pending, not running; do not promise a completion time.'
|
|
245
|
+
const instruction = `Async ticket ${ticket.id} persisted. ${state} Stop research and answer from existing results. Never claim a file was created when its tool call failed or was refused. For any successfully written output, call present with its exact existing path before the final response. Mention ticket ${ticket.id}.`
|
|
204
246
|
return {
|
|
205
247
|
isError: true,
|
|
206
248
|
error: {
|
|
@@ -221,6 +263,7 @@ type AgentTurnState = {
|
|
|
221
263
|
startedAt: number
|
|
222
264
|
policy: TurnPolicy
|
|
223
265
|
userMessage?: string
|
|
266
|
+
handoff?: Promise<TurnHandoffTicket>
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
/**
|
|
@@ -250,6 +293,7 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
250
293
|
|
|
251
294
|
const stateByAgent = new Map<string, AgentTurnState>()
|
|
252
295
|
const softExecutions = new Map<ToolExecutionToken, { agentId: string; exit: string }>()
|
|
296
|
+
const handoffExecutions = new Map<ToolExecutionToken, { agentId: string; contexts: NonNullable<ToolExecutionResult['additionalContexts']> }>()
|
|
253
297
|
const finalOnlyDisposers = new Map<string, () => void>()
|
|
254
298
|
|
|
255
299
|
const clearAgent = (agentId: string) => {
|
|
@@ -257,6 +301,9 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
257
301
|
for (const [token, owner] of softExecutions) {
|
|
258
302
|
if (owner.agentId === agentId) softExecutions.delete(token)
|
|
259
303
|
}
|
|
304
|
+
for (const [token, owner] of handoffExecutions) {
|
|
305
|
+
if (owner.agentId === agentId) handoffExecutions.delete(token)
|
|
306
|
+
}
|
|
260
307
|
finalOnlyDisposers.get(agentId)?.()
|
|
261
308
|
finalOnlyDisposers.delete(agentId)
|
|
262
309
|
}
|
|
@@ -273,7 +320,7 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
273
320
|
}
|
|
274
321
|
const inheritedNames = globalTools.schemas()
|
|
275
322
|
.map(schema => schema.name)
|
|
276
|
-
.filter(name => name !== 'run_code')
|
|
323
|
+
.filter(name => name !== 'run_code' && name !== 'present')
|
|
277
324
|
if (inheritedNames.length === 0) return
|
|
278
325
|
finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }))
|
|
279
326
|
}
|
|
@@ -324,18 +371,61 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
324
371
|
?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } }
|
|
325
372
|
stateByAgent.set(agentId, state)
|
|
326
373
|
|
|
374
|
+
// Present validates files on disk and publishes native deliverable events;
|
|
375
|
+
// it must remain callable after the research window closes.
|
|
376
|
+
if (exec.name === 'present') return next()
|
|
377
|
+
|
|
327
378
|
const elapsed = now() - state.startedAt
|
|
328
379
|
if (elapsed > state.policy.hardMs) {
|
|
329
|
-
// Synchronous schema suppression so same-step follow-up calls cannot
|
|
330
|
-
// loop on refusal results (the 2026-09-02 trajectory failure).
|
|
331
|
-
enterFinalOnly(exec.agent)
|
|
332
380
|
if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
|
|
333
|
-
|
|
334
|
-
.
|
|
381
|
+
const root = options.stateRoot === '.'
|
|
382
|
+
? (exec.agent.session?.header.cwd ?? process.cwd()) : resolve(options.stateRoot)
|
|
383
|
+
const written = exec.name === 'write' && !state.handoff ? Promise.resolve().then(() => next()) : undefined
|
|
384
|
+
if (!state.handoff) {
|
|
385
|
+
const session = exec.agent.session
|
|
386
|
+
// Reserve the handoff before executing the write body. Parallel or
|
|
387
|
+
// repeated calls see the same promise and cannot write/dispatch twice.
|
|
388
|
+
state.handoff = (async () => {
|
|
389
|
+
try { await written } finally { enterFinalOnly(exec.agent!) }
|
|
390
|
+
let context: string | undefined
|
|
391
|
+
let contextError: string | undefined
|
|
392
|
+
try { context = handoffContext(session?.snapshotEvents() ?? []) }
|
|
393
|
+
catch (error) { contextError = error instanceof Error ? error.message : 'handoff context unavailable' }
|
|
394
|
+
const ticket = await writeTurnHandoffTicket(root, state.userMessage!, {
|
|
395
|
+
workspaceCwd: session?.header.cwd,
|
|
396
|
+
ownerSessionId: session ? String(session.id) : undefined,
|
|
397
|
+
context,
|
|
398
|
+
})
|
|
399
|
+
if (contextError) {
|
|
400
|
+
await settleTurnHandoffTicket(root, ticket, 'failed', `# Planning paused\n\n${contextError}`, contextError)
|
|
401
|
+
return { ...ticket, status: 'failed', error: contextError }
|
|
402
|
+
}
|
|
403
|
+
return options.startHandoff ? options.startHandoff(ticket, exec.agent!, root) : ticket
|
|
404
|
+
})()
|
|
405
|
+
}
|
|
406
|
+
// The triggering call may be the output write itself. Execute it once,
|
|
407
|
+
// preserve its actual outcome, then require native present. Attach a
|
|
408
|
+
// rejection handler now even if the pending tool itself rejects.
|
|
409
|
+
const handoff = state.handoff.then(handoffResult, () => exhaustionResult(`${TURN_DEADLINE_EXHAUSTED}: ticket persistence failed; no background worker was started`))
|
|
410
|
+
return (async () => {
|
|
411
|
+
const output = await written
|
|
412
|
+
const guidance = await handoff
|
|
413
|
+
if (output) {
|
|
414
|
+
handoffExecutions.set(exec.token, { agentId, contexts: guidance.additionalContexts ?? [] })
|
|
415
|
+
// Keep the registry's canonical result object. Rebuilding it after
|
|
416
|
+
// restricting inherited schemas would make DSH re-resolve a tool
|
|
417
|
+
// that has just been hidden and report UNKNOWN_TOOL after saving.
|
|
418
|
+
return output
|
|
419
|
+
}
|
|
420
|
+
return guidance
|
|
421
|
+
})()
|
|
335
422
|
.catch(() => exhaustionResult(
|
|
336
423
|
`${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`,
|
|
337
424
|
))
|
|
338
425
|
}
|
|
426
|
+
// Suppress inherited research schemas immediately on refusal; DSH's
|
|
427
|
+
// scoped schemas remain visible but hit this same per-call guard.
|
|
428
|
+
enterFinalOnly(exec.agent)
|
|
339
429
|
return Promise.resolve(exhaustionResult(
|
|
340
430
|
`${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`,
|
|
341
431
|
))
|
|
@@ -349,16 +439,19 @@ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions =
|
|
|
349
439
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
|
350
440
|
return next().then(decision => {
|
|
351
441
|
const owner = softExecutions.get(exec.token)
|
|
352
|
-
|
|
442
|
+
const handoff = handoffExecutions.get(exec.token)
|
|
443
|
+
handoffExecutions.delete(exec.token)
|
|
444
|
+
if (!owner && !handoff) return decision
|
|
353
445
|
softExecutions.delete(exec.token)
|
|
354
|
-
const text = owner
|
|
446
|
+
const text = owner?.exit === 'handoff'
|
|
355
447
|
? 'Turn has crossed the soft deadline of a deep-planning turn. Wrap up context gathering now; the turn will hand off to background planning at the hard deadline.'
|
|
356
448
|
: 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.'
|
|
357
449
|
return {
|
|
358
450
|
...decision,
|
|
359
451
|
additionalContexts: [
|
|
360
452
|
...(decision.additionalContexts ?? []),
|
|
361
|
-
|
|
453
|
+
...(handoff?.contexts ?? []),
|
|
454
|
+
...(owner ? [convergenceContext(TURN_DEADLINE_SOFT, text) as never] : []),
|
|
362
455
|
],
|
|
363
456
|
}
|
|
364
457
|
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Connect durable planning tickets to DSH's owned jobs and managed processes. */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
7
|
+
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
|
|
8
|
+
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
|
9
|
+
import { settleTurnHandoffTicket, writeTicketJson, type TurnHandoffTicket } from './turn-deadline.ts'
|
|
10
|
+
|
|
11
|
+
declare module '@deepseek-ai/dsh-jobs' {
|
|
12
|
+
interface JobKindMap { 'gotry-planning': 'gotry-planning' }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
|
|
17
|
+
export async function startTurnHandoffJob(
|
|
18
|
+
ctx: Context, ticket: TurnHandoffTicket, owner: Agent, stateRoot: string,
|
|
19
|
+
): Promise<TurnHandoffTicket> {
|
|
20
|
+
if (ticket.status !== 'open') return ticket
|
|
21
|
+
const jobs = ctx.get('jobs')
|
|
22
|
+
const subprocess = ctx.get('subprocess')
|
|
23
|
+
if (!jobs || !subprocess) return ticket // Queued, never announced as running.
|
|
24
|
+
let current = ticket
|
|
25
|
+
let handle: SubprocessHandle | undefined
|
|
26
|
+
let cancelled = false
|
|
27
|
+
let release!: (ok: boolean) => void
|
|
28
|
+
const prepared = new Promise<boolean>(resolve => { release = resolve })
|
|
29
|
+
let announceStarted!: () => void
|
|
30
|
+
const started = new Promise<void>(resolve => { announceStarted = resolve })
|
|
31
|
+
const fail = async (reason: string) => {
|
|
32
|
+
await settleTurnHandoffTicket(stateRoot, current, 'failed', `# Planning failed\n\n${reason}\n\nNo completed plan was produced.`, reason)
|
|
33
|
+
current = { ...current, status: 'failed', error: reason }
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const jobId = jobs.start({
|
|
37
|
+
kind: 'gotry-planning', owner: owner.id, label: `${ticket.id}: ${ticket.objective.slice(0, 100)}`, outputLimitBytes: 12_000,
|
|
38
|
+
run() {
|
|
39
|
+
return {
|
|
40
|
+
cancel() { cancelled = true; handle?.terminate() },
|
|
41
|
+
done: (async (): Promise<JobOutcome> => {
|
|
42
|
+
if (!await prepared) { announceStarted(); return { status: 'failed', detail: 'ticket persistence failed' } }
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
try {
|
|
45
|
+
if (cancelled) throw new Error('planning cancelled')
|
|
46
|
+
const extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js'
|
|
47
|
+
const collector = join(import.meta.dirname, '..', 'scripts', `turn-handoff-collect.${extension}`)
|
|
48
|
+
const argv = extension === 'ts'
|
|
49
|
+
? [process.execPath, require.resolve('tsx/cli'), collector, ticket.id, stateRoot]
|
|
50
|
+
: [process.execPath, collector, ticket.id, stateRoot]
|
|
51
|
+
handle = subprocess.spawn({
|
|
52
|
+
argv, cwd: ticket.workspaceCwd ?? stateRoot,
|
|
53
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 16_000 }, stderr: { maxBytes: 16_000 } },
|
|
54
|
+
// Collector cleanup includes the wrapper's detached DSH group
|
|
55
|
+
// (5s+1s), then its own 8s+1s bound and durable settlement.
|
|
56
|
+
graceMs: 11_000,
|
|
57
|
+
// This dedicated planning child inherits the configured model
|
|
58
|
+
// environment explicitly; secrets never enter tickets or output.
|
|
59
|
+
env: { GOTRY_HANDOFF_JOB_ID: current.jobId,
|
|
60
|
+
GOTRY_HANDOFF_PLANNER_BIN: process.env.GOTRY_HANDOFF_PLANNER_BIN,
|
|
61
|
+
GOTRY_HANDOFF_PLANNER_TIMEOUT_MS: process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS,
|
|
62
|
+
DSH_HOME: process.env.DSH_HOME,
|
|
63
|
+
LLM_API_KEY: process.env.LLM_API_KEY, LLM_BASE_URL: process.env.LLM_BASE_URL,
|
|
64
|
+
LLM_MODEL: process.env.LLM_MODEL, GOTRY_LLM_MODEL: process.env.GOTRY_LLM_MODEL,
|
|
65
|
+
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL },
|
|
66
|
+
})
|
|
67
|
+
announceStarted()
|
|
68
|
+
const plannerTimeout = Number(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS)
|
|
69
|
+
const timeout = Number.isSafeInteger(plannerTimeout) && plannerTimeout > 0 ? plannerTimeout : 900_000
|
|
70
|
+
timer = setTimeout(() => handle!.terminate(), timeout + 12_000)
|
|
71
|
+
const direct = await handle.done.then(
|
|
72
|
+
outcome => ({ ok: true as const, outcome }),
|
|
73
|
+
error => ({ ok: false as const, error }),
|
|
74
|
+
)
|
|
75
|
+
// Direct success or failure does not prove managed-range exit.
|
|
76
|
+
if (!direct.ok) handle.terminate()
|
|
77
|
+
try { await handle.waitForExit() }
|
|
78
|
+
catch (cleanupError) {
|
|
79
|
+
handle.terminate()
|
|
80
|
+
const cleanupDiagnostic = cleanupError instanceof Error ? cleanupError.message : 'unknown cleanup error'
|
|
81
|
+
const cleanupReason = `managed-range cleanup could not be confirmed: ${cleanupDiagnostic}`
|
|
82
|
+
if (!direct.ok) {
|
|
83
|
+
const diagnostic = direct.error instanceof Error ? direct.error.message : 'planning failed'
|
|
84
|
+
throw new AggregateError([direct.error, cleanupError], `${diagnostic}; ${cleanupReason}`)
|
|
85
|
+
}
|
|
86
|
+
throw new Error(cleanupReason, { cause: cleanupError })
|
|
87
|
+
}
|
|
88
|
+
if (!direct.ok) throw direct.error
|
|
89
|
+
const outcome = direct.outcome
|
|
90
|
+
const path = join(stateRoot, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`)
|
|
91
|
+
const settled = JSON.parse(await readFile(path, 'utf8')) as TurnHandoffTicket
|
|
92
|
+
if (cancelled) throw new Error('planning cancelled')
|
|
93
|
+
if (outcome.exitCode !== 0 || settled.status !== 'settled' || settled.deliverableFile !== `${ticket.id}.deliverable.md`) {
|
|
94
|
+
throw new Error(settled.error ?? `planning collector failed (exit ${outcome.exitCode ?? outcome.signal})`)
|
|
95
|
+
}
|
|
96
|
+
const deliverablePath = join(stateRoot, 'gotry-state', 'turn-handoffs', settled.deliverableFile)
|
|
97
|
+
const content = await readFile(deliverablePath, 'utf8')
|
|
98
|
+
if (!content.trim()) throw new Error('planning collector produced an empty deliverable')
|
|
99
|
+
current = settled
|
|
100
|
+
return { status: 'completed', detail: `ticket ${ticket.id} settled`, result: `Planning completed: ${ticket.id}. Deliverable: ${deliverablePath}\nCall present with this exact path, then report the result.\n\n${content.slice(0, 8_000)}` }
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const reason = error instanceof Error ? error.message : 'planning failed'
|
|
103
|
+
await fail(reason)
|
|
104
|
+
return { status: cancelled ? 'killed' : 'failed', detail: reason, result: `Ticket ${ticket.id} failed: ${reason}` }
|
|
105
|
+
} finally { if (timer) clearTimeout(timer); announceStarted() }
|
|
106
|
+
})(),
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
current = { ...ticket, status: 'running', jobId }
|
|
111
|
+
await writeTicketJson(stateRoot, current)
|
|
112
|
+
release(true)
|
|
113
|
+
await started
|
|
114
|
+
return current
|
|
115
|
+
} catch (error) {
|
|
116
|
+
release(false)
|
|
117
|
+
await fail(error instanceof Error ? error.message : 'background job registration failed')
|
|
118
|
+
return current
|
|
119
|
+
}
|
|
120
|
+
}
|