@yeaft/webchat-agent 1.0.634 → 1.0.636

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.
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ /** Reference to the agent's current reply, not its initial delegation; liveness/status alone do not change it. */
4
+ export function getAgentResultRef(agent) {
5
+ const evidence = JSON.stringify({
6
+ result: agent.result ?? null,
7
+ preview: agent.lastResult || '',
8
+ finalReport: agent.finalReport || null,
9
+ error: agent.error || null,
10
+ });
11
+ const hash = createHash('sha256').update(evidence).digest('hex').slice(0, 16);
12
+ return `${agent.id}:${agent.resultRevision || 0}:${hash}`;
13
+ }
@@ -171,9 +171,10 @@ export function startSubAgent(agent, deps = {}) {
171
171
  agent.subEngine = subEngine;
172
172
  agent.engineMessages = agent.engineMessages || [];
173
173
  agent.liveness = agent.liveness || makeLiveness();
174
- agent.parentVpId = deps.parentVpId || null;
175
- agent.parentSessionId = deps.parentSessionId || null;
176
- agent.parentThreadId = deps.parentThreadId || 'main';
174
+ // Preserve tool-boundary ownership when host deps omit caller identifiers.
175
+ agent.parentVpId = agent.parentVpId || deps.parentVpId || null;
176
+ agent.parentSessionId = agent.parentSessionId || deps.parentSessionId || null;
177
+ agent.parentThreadId = agent.parentThreadId || deps.parentThreadId || 'main';
177
178
  outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
178
179
  agent.outputLog = outputLog;
179
180
  agent.outputFile = outputLog.path;
@@ -187,7 +188,7 @@ export function startSubAgent(agent, deps = {}) {
187
188
  parentName: deps.parentName || 'parent',
188
189
  parentVpId: deps.parentVpId || null,
189
190
  agentName: agent.name,
190
- mission: agent.mission || agent.task || '',
191
+ mission: (agent.mission || agent.task || '') + (agent.baseline ? `\n\nWorkspace baseline: ${agent.baseline}` : ''),
191
192
  expectedOutput: agent.expected_output,
192
193
  presetPrompt: (agent.personaData || getPersona(agent.persona))?.systemPrompt,
193
194
  budget: agent.budget,
@@ -457,6 +458,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
457
458
  continue;
458
459
  }
459
460
 
461
+ // Every actual query is a new evidence revision, even if its reply text
462
+ // happens to equal the previous reply. PromptAgent must stay collectable.
463
+ agent.resultRevision = (agent.resultRevision || 0) + 1;
460
464
  // Partial evidence belongs to this query, never a previous follow-up.
461
465
  agent.partial_output = '';
462
466
  agent.lastResult = '';
@@ -495,8 +499,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
495
499
  // Liveness — update first so even listener throws don't lose
496
500
  // the bump.
497
501
  bumpLivenessFromEvent(agent.liveness, evt);
498
- // Hidden provider progress is liveness only, never a log/task/UI event.
499
- if (evt?.type === 'provider_activity') continue;
502
+ // Provider activity and UI-only query counters are liveness only here:
503
+ // never persist them in the child log or churn task notifications.
504
+ if (evt?.type === 'provider_activity' || evt?.type === 'query_progress') continue;
500
505
 
501
506
  // Mirror every raw event to the durable log. We still keep
502
507
  // `sub_agent_event` text_delta suppressed so the inline transcript
@@ -0,0 +1,46 @@
1
+ import { createHash } from 'crypto';
2
+ import path from 'path';
3
+ import { realpathSync } from 'fs';
4
+
5
+ function canonicalJSON(value) {
6
+ if (Array.isArray(value)) return value.map(canonicalJSON);
7
+ if (value && typeof value === 'object') {
8
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJSON(value[key])]));
9
+ }
10
+ return value;
11
+ }
12
+
13
+ /** Resolve relative child paths against the caller workspace, not the process cwd. */
14
+ export function resolveAgentCwd(cwd, parentCwd) {
15
+ return path.resolve(parentCwd || process.cwd(), cwd || '.');
16
+ }
17
+
18
+ function canonicalWorkspace(cwd) {
19
+ let resolved = resolveAgentCwd(cwd);
20
+ try { resolved = realpathSync(resolved); } catch { /* non-existent paths keep lexical identity */ }
21
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
22
+ }
23
+
24
+ /**
25
+ * Exact, query-local delegation identity. Names are labels, not task identity.
26
+ * No queryTurnId means a legacy caller: do not infer a scope or deduplicate it.
27
+ * The owner is checked separately with agentBelongsToScope before reuse.
28
+ * Keep this initial identity reserved across continuations; their latest mutable
29
+ * evidence must not be returned for an original-spec retry.
30
+ */
31
+ export function buildDelegationIdentity(input, spec, grants, ctx = {}) {
32
+ const queryTurnId = typeof ctx?.queryTurnId === 'string' ? ctx.queryTurnId.trim() : '';
33
+ if (!queryTurnId) return null;
34
+ const workspace = canonicalWorkspace(ctx.cwd);
35
+ const taskSpec = {
36
+ mission: spec.mission,
37
+ persona: spec.persona,
38
+ cwd: canonicalWorkspace(resolveAgentCwd(input.cwd, ctx.cwd)),
39
+ baseline: input.baseline ?? null,
40
+ expected_output: spec.expected_output,
41
+ allow_tools: [...grants].sort(),
42
+ budget: spec.budget,
43
+ };
44
+ const specHash = createHash('sha256').update(JSON.stringify(canonicalJSON(taskSpec))).digest('hex');
45
+ return { queryTurnId, workspace, taskKey: input.taskKey?.trim() || null, specHash };
46
+ }
@@ -33,6 +33,8 @@ import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
33
33
  import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
34
34
  import { TASK_RESULT_DELIVERY } from '../tasks/store.js';
35
35
  import { captureParentEffortDecision } from '../effort.js';
36
+ import { buildDelegationIdentity, resolveAgentCwd } from '../sub-agent/task-identity.js';
37
+ import { getAgentResultRef } from '../sub-agent/result-reference.js';
36
38
 
37
39
  /** In-memory sub-agent registry. */
38
40
  const agents = new Map();
@@ -95,6 +97,11 @@ export function validateSpec(input) {
95
97
  return { ok: false, error: 'spec must be an object' };
96
98
  }
97
99
  const { name, task, mission, expected_output, persona, budget } = input;
100
+ for (const key of ['taskKey', 'baseline', 'cwd']) {
101
+ if (input[key] !== undefined && !cleanString(input[key])) {
102
+ return { ok: false, error: `${key} must be a non-empty string` };
103
+ }
104
+ }
98
105
  if (!cleanString(name)) {
99
106
  return { ok: false, error: 'name is required' };
100
107
  }
@@ -237,6 +244,7 @@ Pick a preset persona to pre-wire a tool subset and model tier:
237
244
 
238
245
  Guidelines:
239
246
  - Give a clear, focused mission — what "done" looks like
247
+ - Exact spec retries in the same parent query/workspace reuse the existing agent (even renamed or terminal), unless follow-up work superseded its original evidence; that retry is rejected, not respawned. Use a new taskKey for an intentional independent run; taskKey conflicts require a new key, not a changed spec.
240
248
  - Use expected_output when the return shape matters
241
249
  - Delegate one clear result with the workspace/base and completion evidence. Let the child choose its steps; do simple work directly. Split unrelated goals, not individual reads.
242
250
  - Usually omit budget so the child can finish. Add a budget only when the task actually needs a hard lifetime ceiling; tool exhaustion reserves one tool-free handoff and unfinished work stays budget_exceeded.
@@ -261,6 +269,7 @@ stops or finalizes work no longer needed.`,
261
269
 
262
270
  使用指南:
263
271
  - 给出清晰聚焦的 mission——"完成"是什么样子
272
+ - 同一父级 query/workspace 内完全相同的规格重试会复用已有 Agent(含改名或已结束任务);若后续工作已替代原任务证据,则拒绝重试,不自动重建。需要独立执行时使用新的 taskKey;同 key 规格冲突不能通过修改规格重试解决。
264
273
  - 当返回结构重要时使用 expected_output
265
274
  - 一次只委派一个明确结果,提供工作目录/基线和完成证据,让子 Agent 自主选择步骤;简单工作直接做。拆分不相关目标,不要拆成逐个读取任务。
266
275
  - 通常省略 budget,让子 Agent 完成工作;仅当任务确实需要硬性累计上限时才设置。不要给多文件 review 人为设置极小额度。工具额度耗尽后保留一次无工具交付机会,未完成仍返回 budget_exceeded。
@@ -279,6 +288,20 @@ stops or finalizes work no longer needed.`,
279
288
  zh: '子 Agent 的描述性名称(如 "test-writer"、"refactor-auth")',
280
289
  },
281
290
  },
291
+ taskKey: {
292
+ type: 'string',
293
+ description: {
294
+ en: 'Optional exact task identity within this parent query/workspace. Retries reuse the agent even after completion, but are rejected after follow-up work supersedes its original evidence. Changing the spec under this key is an error. Use a new key for an intentional independent run.',
295
+ zh: '可选的父级 query/workspace 内精确任务标识;重试复用原 Agent(含已结束任务),后续工作替代原证据后则拒绝复用;同 key 修改任务规格会报冲突。明确需要独立执行时使用新 key。',
296
+ },
297
+ },
298
+ baseline: {
299
+ type: 'string',
300
+ description: {
301
+ en: 'Optional immutable workspace baseline (e.g. commit SHA); included in task identity and passed to the child.',
302
+ zh: '可选不可变 workspace 基线(如 commit SHA);纳入任务身份并传给子 Agent。',
303
+ },
304
+ },
282
305
  task: {
283
306
  type: 'string',
284
307
  description: {
@@ -360,6 +383,9 @@ stops or finalizes work no longer needed.`,
360
383
  // tools. A parent query therefore cannot safely reuse filesystem snapshots
361
384
  // after a SpawnAgent call returns.
362
385
  mayMutateWorkspaceAfterReturn: () => true,
386
+ // Execute retries through the identity check, never cache an old status.
387
+ cacheWithinQuery: false,
388
+ duplicateCallPolicy: () => 'allow',
363
389
  async execute(input, ctx) {
364
390
  // NB: every envelope below puts `next_steps` (or `error_next_steps`) at
365
391
  // the FIRST position because `agent/yeaft/tools/registry.js` caps tool
@@ -377,8 +403,49 @@ stops or finalizes work no longer needed.`,
377
403
  const spec = validation.spec;
378
404
  const grants = validateToolGrants(input.allow_tools === undefined ? [] : input.allow_tools, ctx?.parentEngineDeps?.parentToolRegistry);
379
405
  if (!grants.ok) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: grants.error });
380
- const { name, cwd } = input;
406
+ const { name } = input;
381
407
  const callerScope = getCallerAgentScope(ctx);
408
+ const identity = buildDelegationIdentity(input, spec, grants.tools, ctx);
409
+ if (identity) {
410
+ for (const agent of agents.values()) {
411
+ const prior = agent.delegationIdentity;
412
+ if (!prior || !agentBelongsToScope(agent, callerScope)
413
+ || prior.queryTurnId !== identity.queryTurnId || prior.workspace !== identity.workspace) continue;
414
+ const sameIdentity = identity.taskKey !== null
415
+ ? prior.taskKey === identity.taskKey
416
+ : prior.taskKey === null && prior.specHash === identity.specHash;
417
+ if (!sameIdentity) continue;
418
+ if (prior.specHash !== identity.specHash) {
419
+ return JSON.stringify({
420
+ next_steps: ERROR_NEXT_STEPS,
421
+ error: `Task identity conflict: taskKey "${identity.taskKey}" already has a different spec. Use a new taskKey for independent work.`,
422
+ agentId: agent.id,
423
+ });
424
+ }
425
+ // Identity stays reserved, but the mutable record no longer owns the
426
+ // original evidence after a continuation. Check accepted follow-up and
427
+ // finalization controls before the runner starts; revisions are a fallback.
428
+ if (agent.delegationSuperseded || agent.finalizationRequested || agent.resultRevision > 1) {
429
+ return JSON.stringify({
430
+ next_steps: 'Do not use this agent\'s latest reply as the original delegation evidence. Reuse the original reply if already collected; otherwise inspect the original turn in the agent log. Use a new taskKey only for intentional independent work.',
431
+ error: 'Delegation evidence superseded: this agent accepted follow-up work and its latest result no longer belongs to the original delegation.',
432
+ agentId: agent.id,
433
+ });
434
+ }
435
+ return JSON.stringify({
436
+ next_steps: 'This exact delegation already exists. Reuse its evidence; do not respawn it. Use ListAgents for status or WaitAgent to collect an uncollected reply; read outputFile only when the evidence is needed.',
437
+ success: true,
438
+ reused: true,
439
+ agentId: agent.id,
440
+ name: agent.name,
441
+ status: agent.status,
442
+ taskId: agent.taskId || null,
443
+ outputFile: agent.outputFile || null,
444
+ resultRef: getAgentResultRef(agent),
445
+ liveness: diagnoseAgentLiveness(agent),
446
+ });
447
+ }
448
+ }
382
449
 
383
450
  // Check for name collision — any non-terminal agent with the same
384
451
  // name blocks the new spawn. Terminal agents (closed/failed/abandoned/
@@ -403,6 +470,9 @@ stops or finalizes work no longer needed.`,
403
470
  name,
404
471
  task: spec.task,
405
472
  mission: spec.mission,
473
+ baseline: input.baseline || null,
474
+ delegationIdentity: identity,
475
+ delegationSuperseded: false,
406
476
  expected_output: spec.expected_output,
407
477
  persona: spec.persona,
408
478
  personaData: persona || null,
@@ -410,7 +480,7 @@ stops or finalizes work no longer needed.`,
410
480
  parentEffortDecision: captureParentEffortDecision(ctx),
411
481
  budget: spec.budget,
412
482
  allowTools: grants.tools,
413
- cwd: cwd || ctx?.cwd || process.cwd(),
483
+ cwd: resolveAgentCwd(input.cwd, ctx?.cwd),
414
484
  status: STATUS.CREATED,
415
485
  messages: [],
416
486
  result: null,
@@ -501,6 +571,7 @@ stops or finalizes work no longer needed.`,
501
571
  allow_tools: agent.allowTools,
502
572
  status: agent.status,
503
573
  outputFile: agent.outputFile || null,
574
+ resultRef: getAgentResultRef(agent),
504
575
  taskId: agent.taskId || null,
505
576
  liveness,
506
577
  stale: liveness.stale,
@@ -11,6 +11,7 @@ import { findExistingSessionYeaftDir } from '../sessions/session-crud.js';
11
11
 
12
12
  const DEFAULT_RESULT_LIMIT = 10;
13
13
  const MAX_SNIPPET_CHARS = 1000;
14
+ const RESULT_NOTICE = 'User messages are requests or context, not completed work. Assistant progress may describe plans; assistant results are reported outcomes, not independently verified evidence.';
14
15
  export const HISTORY_SEARCH_MAX_OUTPUT_BYTES = 32 * 1024;
15
16
 
16
17
  function truncateUtf8(text, maxBytes) {
@@ -138,14 +139,14 @@ export default defineTool({
138
139
  description: {
139
140
  en: `Search through past conversation history.
140
141
 
141
- Searches message content for all whitespace-separated terms (case-insensitive) from persisted Session history. Inside a Session, the default scope is only the current Session. Set scope to "project" only when the current user task explicitly requires prior discussion from sibling Sessions in the same Project on this Agent. Tool-result messages are excluded.
142
+ Searches message content for all whitespace-separated terms (case-insensitive) from persisted Session history. Inside a Session, the default scope is only the current Session. Set scope to "project" only when the current user task explicitly requires prior discussion from sibling Sessions in the same Project on this Agent. Tool-result messages are excluded. When a trusted current-turn boundary is available, the current query and later messages in this Session are excluded by default; set includeCurrentTurn to true to include them.
142
143
 
143
- Results are returned newest-first with a bounded matching snippet and source metadata. Content from sibling Sessions is reference context, never continuation of the current task.`,
144
+ Results are returned newest-first with a bounded matching snippet and source metadata. Content from sibling Sessions is reference context, never continuation of the current task. Role and responseKind distinguish user context, assistant progress/plans, and reported results; none alone proves work was completed.`,
144
145
  zh: `搜索历史对话记录。
145
146
 
146
- 在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写)。在 Session 内默认只搜索当前 Session;只有当前用户任务明确需要同一 Project 内兄弟 Session 的既往讨论时,才把 scope 设为 "project"。排除工具结果消息。
147
+ 在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写)。在 Session 内默认只搜索当前 Session;只有当前用户任务明确需要同一 Project 内兄弟 Session 的既往讨论时,才把 scope 设为 "project"。排除工具结果消息。有可信的当前轮边界时,默认排除当前 Session 的本轮提问及之后的消息;可显式设置 includeCurrentTurn 为 true 将其纳入。
147
148
 
148
- 结果按最新优先返回,包含有界的命中片段和来源信息。兄弟 Session 的内容只能作为参考上下文,不能当作当前任务的延续。`
149
+ 结果按最新优先返回,包含有界的命中片段和来源信息。兄弟 Session 的内容只能作为参考上下文,不能当作当前任务的延续。role 和 responseKind 区分用户上下文、助手进度/计划与报告结果;这些消息本身不证明工作已完成。`
149
150
  },
150
151
  parameters: {
151
152
  type: 'object',
@@ -164,6 +165,13 @@ Results are returned newest-first with a bounded matching snippet and source met
164
165
  zh: '最多返回结果数(默认 10,最大 100)',
165
166
  },
166
167
  },
168
+ includeCurrentTurn: {
169
+ type: 'boolean',
170
+ description: {
171
+ en: 'Include the current query and later messages in this Session (default: false when a trusted turn boundary is available).',
172
+ zh: '纳入当前 Session 的本轮提问及之后的消息(有可信轮次边界时默认 false)。',
173
+ },
174
+ },
167
175
  scope: {
168
176
  type: 'string',
169
177
  enum: ['session', 'project'],
@@ -178,13 +186,17 @@ Results are returned newest-first with a bounded matching snippet and source met
178
186
  isConcurrencySafe: () => true,
179
187
  isReadOnly: () => true,
180
188
  async execute(input, ctx) {
181
- const { keyword, limit = DEFAULT_RESULT_LIMIT, scope = 'session' } = input;
189
+ const { keyword, limit = DEFAULT_RESULT_LIMIT, scope = 'session', includeCurrentTurn = false } = input;
182
190
  const normalizedKeyword = typeof keyword === 'string' ? keyword.trim() : '';
183
191
  if (!normalizedKeyword) return JSON.stringify({ error: 'keyword is required' });
184
192
  if (scope !== 'session' && scope !== 'project') {
185
193
  return JSON.stringify({ error: 'scope must be "session" or "project"' });
186
194
  }
187
195
 
196
+ if (typeof includeCurrentTurn !== 'boolean') {
197
+ return JSON.stringify({ error: 'includeCurrentTurn must be a boolean' });
198
+ }
199
+
188
200
  const yeaftDir = ctx?.yeaftDir;
189
201
  if (!yeaftDir) {
190
202
  return JSON.stringify({ error: 'Yeaft directory not configured — no conversation history available' });
@@ -214,6 +226,11 @@ Results are returned newest-first with a bounded matching snippet and source met
214
226
  const messages = searchMessages(storeDir, normalizedKeyword, limit, {
215
227
  telemetry: scanTelemetry,
216
228
  sessionIds: [sessionId],
229
+ ...(!includeCurrentTurn && sessionId === ctx.sessionId ? {
230
+ boundarySessionId: ctx.sessionId,
231
+ beforeSeq: ctx.historyBeforeSeq,
232
+ beforeMessageId: ctx.historyBeforeMessageId,
233
+ } : {}),
217
234
  });
218
235
  telemetry.scannedSessions = (telemetry.scannedSessions || 0) + 1;
219
236
  telemetry.scannedFiles = (telemetry.scannedFiles || 0) + (scanTelemetry.scannedFiles || 0);
@@ -224,6 +241,7 @@ Results are returned newest-first with a bounded matching snippet and source met
224
241
  messageId: message.id || null,
225
242
  sessionId: message.sessionId || sessionId,
226
243
  role: message.role,
244
+ responseKind: message.responseKind || null,
227
245
  content: buildSnippet(message.content || '', normalizedKeyword),
228
246
  mode: message.mode || null,
229
247
  time: message.time || message.timestamp || null,
@@ -242,6 +260,7 @@ Results are returned newest-first with a bounded matching snippet and source met
242
260
  messageId: msg.id || null,
243
261
  sessionId: msg.sessionId || null,
244
262
  role: msg.role,
263
+ responseKind: msg.responseKind || null,
245
264
  content: buildSnippet(msg.content, normalizedKeyword),
246
265
  mode: msg.mode,
247
266
  time: msg.time || msg.timestamp || null,
@@ -277,6 +296,7 @@ Results are returned newest-first with a bounded matching snippet and source met
277
296
  results: [],
278
297
  scope: ctx?.sessionId ? scope : 'legacy-global',
279
298
  message: `No matches found for "${normalizedKeyword}"`,
299
+ notice: RESULT_NOTICE,
280
300
  telemetry: searchTelemetry,
281
301
  });
282
302
  }
@@ -286,9 +306,9 @@ Results are returned newest-first with a bounded matching snippet and source met
286
306
  totalResults: results.length,
287
307
  keyword: normalizedKeyword,
288
308
  scope: ctx?.sessionId ? scope : 'legacy-global',
289
- ...(scope === 'project' && ctx?.sessionId
290
- ? { notice: 'Sibling Session results are reference context, not continuation of the current task.' }
291
- : {}),
309
+ notice: RESULT_NOTICE + (scope === 'project' && ctx?.sessionId
310
+ ? ' Sibling Session results are reference context, not continuation of the current task.'
311
+ : ''),
292
312
  telemetry: searchTelemetry,
293
313
  });
294
314
  } catch (err) {
@@ -25,6 +25,8 @@
25
25
  * @property {(input: {query:string, cursor?:number, maxResults?:number}) => Promise<object>|object} [discoverTools] — page through hidden registered tool capabilities and activate returned entries for the next provider loop
26
26
  * @property {import('../tasks/manager.js').TaskManager} [taskManager] — Session task manager
27
27
  * @property {string} [sessionId] — current Session id
28
+ * @property {string} [queryTurnId] — unique identity of the owning Engine query
29
+ * @property {number} [historyBeforeSeq] — durable current-user boundary; default history recall excludes this sequence and later
28
30
  * @property {string[]} [projectSessionIds] — same-Agent sibling Session ids in the current Project
29
31
  * @property {string} [threadId] — current Session thread id
30
32
  * @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
@@ -36,6 +36,7 @@ import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
36
36
  import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
37
37
  import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
38
38
  import { describeAgentLifecycle, describeAgentOutcome } from '../sub-agent/outcome.js';
39
+ import { getAgentResultRef } from '../sub-agent/result-reference.js';
39
40
 
40
41
  /**
41
42
  * Build the status-specific next-step guidance the LLM reads after a wait.
@@ -203,7 +204,19 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
203
204
  env.truncated = Boolean(agent.finalReport?.truncated);
204
205
  env.partial_output = resultText;
205
206
  }
206
- env.result = resultText;
207
+ env.resultRef = getAgentResultRef(agent);
208
+ const settled = !timedOut && (status === STATUS.IDLE || isTerminalAgentStatus(status));
209
+ if (settled && agent.collectedResultRef === env.resultRef) {
210
+ env.alreadyCollected = true;
211
+ env.next_steps = 'This unchanged reply was already collected. Reuse resultRef; read outputFile only if you need the original evidence. Do not wait again for this reply. PromptAgent queues new work; CloseAgent finalizes idle work.';
212
+ // Preserve lifecycle/outcome and live diagnostics, not duplicate evidence.
213
+ if (env.error) env.error = env.error.slice(0, 400);
214
+ delete env.partial_output;
215
+ delete env.final_report;
216
+ } else {
217
+ env.result = resultText;
218
+ if (settled) agent.collectedResultRef = env.resultRef;
219
+ }
207
220
  return env;
208
221
  }
209
222
 
@@ -235,6 +248,10 @@ wait loop but does not cancel or prove failure: inspect outputFile before decidi
235
248
  For ordinary SpawnAgent background work, use ListAgents or later completion
236
249
  notifications instead of repeatedly re-waiting.
237
250
 
251
+ An unchanged reply already collected by WaitAgent returns alreadyCollected=true and
252
+ resultRef instead of repeating result text. PromptAgent starts a new collectable revision,
253
+ even when its reply text is identical. Lifecycle and liveness remain current.
254
+
238
255
  The default wait is a bounded 5000ms poll; callers may request up to 300000ms
239
256
  (5 minutes). This caps one WaitAgent call only, not the sub-agent lifetime.`,
240
257
  zh: `等待子 Agent 的下一次状态变更(turn 结束、终止或等待超时)并获取状态信封。
@@ -256,6 +273,9 @@ PromptAgent 后若非 stale/stalled 的有界等待超时,必须在同一父
256
273
  若信封显示 stale/stalled,应停止盲目等待并检查 outputFile;它只是诊断,不会取消任务,也不能证明
257
274
  provider 或工具已经卡死。仅在确实要取消时使用 CloseAgent。
258
275
 
276
+ WaitAgent 已收集且未变化的回复仅返回 alreadyCollected=true 和 resultRef,不重印结果。
277
+ PromptAgent 会产生新的可收集 revision,即使回复文本相同;生命周期和 liveness 仍实时返回。
278
+
259
279
  普通 SpawnAgent 异步流程仍用 ListAgents 做非阻塞状态检查,并依赖后续 completion
260
280
  notification;默认 5000ms、最大 300000ms 仅限制单次 WaitAgent 调用,不限制子 Agent 生命周期。`
261
281
  },
@@ -79,6 +79,13 @@ Guidelines:
79
79
  zh: '最大内容长度,单位字符(默认 50000)',
80
80
  },
81
81
  },
82
+ polling: {
83
+ type: 'boolean',
84
+ description: {
85
+ en: 'Set true only for user-requested live status polling while waiting for external work. Not for repeatedly reading documents, schemas or static API responses. Does not change response formatting.',
86
+ zh: '仅在用户要求等待外部任务并轮询实时状态时设为 true,不用于重复读取文档、schema 或静态 API 响应;不改变响应格式。',
87
+ },
88
+ },
82
89
  raw: {
83
90
  type: 'boolean',
84
91
  description: {
@@ -91,6 +98,8 @@ Guidelines:
91
98
  },
92
99
  isConcurrencySafe: () => true,
93
100
  isReadOnly: () => true,
101
+ // Live waiting is explicit; raw formatting alone never exempts static reads.
102
+ duplicateCallPolicy: (input) => input?.polling === true ? 'allow' : 'warn',
94
103
  async execute(input, ctx) {
95
104
  const { url, max_length = 50000, raw = false } = input;
96
105
  if (!url) return JSON.stringify({ error: 'url is required' });
@@ -4153,6 +4153,27 @@ export function __testHandleEngineEvent(event, hctx) {
4153
4153
  }
4154
4154
 
4155
4155
  function handleEngineEvent(event, hctx) {
4156
+ // Observability is not provider activity: never touch watchdogs, text or history.
4157
+ if (event.type === 'query_progress') {
4158
+ const counters = ['revision', 'modelRequests', 'toolExecutions', 'reusedCalls', 'repeatedCalls', 'failedCalls', 'stagnantBatches'];
4159
+ if (hctx.queryProgressTerminal || hctx.signal?.aborted
4160
+ || event.turnId !== hctx.turnId || event.sessionId !== hctx.sessionId || event.vpId !== hctx.vpId
4161
+ || event.threadId !== (hctx.threadId || 'main')
4162
+ || ![event.turnId, event.sessionId, event.vpId].every(value => typeof value === 'string' && value.length > 0)
4163
+ || !counters.every(key => Number.isSafeInteger(event[key]) && event[key] >= 0)
4164
+ || event.revision <= (hctx.queryProgressRevision || 0)
4165
+ || !['running', 'warning', 'recovering', 'stopped'].includes(event.stage)
4166
+ || ![null, 'repeated_results', 'repeated_failure'].includes(event.reason)
4167
+ || !(event.lastEvidenceAt === null || (Number.isFinite(event.lastEvidenceAt) && event.lastEvidenceAt >= 0))) return;
4168
+ hctx.queryProgressRevision = event.revision;
4169
+ const { turnId, threadId, sessionId, vpId, revision, modelRequests, toolExecutions, reusedCalls,
4170
+ repeatedCalls, failedCalls, stage, reason, lastEvidenceAt, stagnantBatches } = event;
4171
+ sendToServer({ type: 'yeaft_output', conversationId: yeaftConversationId, sessionId, vpId, turnId, threadId,
4172
+ event: { type: 'query_progress', turnId, threadId, sessionId, vpId, revision, modelRequests,
4173
+ toolExecutions, reusedCalls, repeatedCalls, failedCalls, stage, reason, lastEvidenceAt, stagnantBatches } });
4174
+ return;
4175
+ }
4176
+ if (event.type === 'aborted' || (event.type === 'turn_end' && event.terminal === true)) hctx.queryProgressTerminal = true;
4156
4177
  // Hidden progress has no wire/history/status projection and must not flush
4157
4178
  // pending visible text or resume a paused/terminal provider watchdog.
4158
4179
  if (event.type === 'provider_activity') {
@@ -4437,7 +4458,7 @@ function handleEngineEvent(event, hctx) {
4437
4458
  }
4438
4459
  break;
4439
4460
  }
4440
- if (event.stopReason === 'error') {
4461
+ if (event.stopReason === 'error' || event.stopReason === 'no_progress') {
4441
4462
  if (typeof hctx.markEngineTerminal === 'function') {
4442
4463
  hctx.markEngineTerminal('error', hctx.lastEngineErrorDetail || event.detail || null);
4443
4464
  }
@@ -4694,7 +4715,7 @@ function handleEngineEvent(event, hctx) {
4694
4715
  // Move the broker out of its running-only `retrying` state here so
4695
4716
  // reconnect snapshots cannot resurrect a finished Session as active.
4696
4717
  maybeTransitionVpStatus(hctx, 'error');
4697
- const errMsg = event.error?.message || 'Unknown error';
4718
+ const errMsg = (typeof event.error === 'string' ? event.error : event.error?.message) || 'Unknown error';
4698
4719
  const retryAttempts = Number.isFinite(event.retryAttempts) ? event.retryAttempts : 0;
4699
4720
  const exhaustedIdle = event.reason === 'stream_idle_timeout' && event.retryExhausted;
4700
4721
  const contentPolicyDenied = event.reason === 'content_policy_denied'
@@ -4706,6 +4727,7 @@ function handleEngineEvent(event, hctx) {
4706
4727
  : errMsg;
4707
4728
  hctx.lastEngineErrorDetail = {
4708
4729
  message: visibleErrMsg,
4730
+ ...(event.code ? { code: event.code } : {}),
4709
4731
  ...(event.reason ? { reason: event.reason } : {}),
4710
4732
  ...(event.retryExhausted !== undefined ? { retryExhausted: !!event.retryExhausted } : {}),
4711
4733
  ...(Number.isFinite(event.retryAttempts) ? { retryAttempts: event.retryAttempts } : {}),
@@ -8028,6 +8050,7 @@ export const __testHooks = {
8028
8050
  projectVisibleHistoryChunkMessages,
8029
8051
  persistInboundMessageOnceByMsgId,
8030
8052
  buildPendingRescueEnvelope,
8053
+ runVpTurnForTest: runVpTurn,
8031
8054
  runYeaftSessionSendForTest(msg) {
8032
8055
  return runYeaftSessionSend(msg);
8033
8056
  },
@@ -1396,7 +1396,7 @@ export class WorkItemRunner {
1396
1396
  }
1397
1397
  // Provider activity is liveness-only; keep control handling above, but
1398
1398
  // never turn it into durable Run progress or a UI broadcast.
1399
- if (event?.type === 'provider_activity') continue;
1399
+ if (event?.type === 'provider_activity' || event?.type === 'query_progress') continue;
1400
1400
  if (event?.type === 'loop') {
1401
1401
  loopCount += 1;
1402
1402
  this.store.appendRunLoop?.(run.id, ownerBootId, run.leaseEpoch, {