@yeaft/webchat-agent 1.0.633 → 1.0.635

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.
Files changed (35) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +145 -127
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/conversation/search.js +25 -9
  9. package/yeaft/engine.js +116 -14
  10. package/yeaft/person/autonomy.js +59 -0
  11. package/yeaft/person/behavior.js +30 -0
  12. package/yeaft/person/capabilities.js +4 -2
  13. package/yeaft/person/capability-experience.js +1 -1
  14. package/yeaft/person/contracts.js +25 -4
  15. package/yeaft/person/execution-goal.js +119 -0
  16. package/yeaft/person/execution-protocol.js +6 -3
  17. package/yeaft/person/inspection.js +1 -0
  18. package/yeaft/person/runtime.js +69 -12
  19. package/yeaft/person/service.js +25 -8
  20. package/yeaft/person/sqlite-repository.js +4 -1
  21. package/yeaft/person/sqlite-store.js +204 -21
  22. package/yeaft/person/task-host.js +25 -6
  23. package/yeaft/person/tool-arguments.js +3 -1
  24. package/yeaft/query-progress.js +201 -0
  25. package/yeaft/sub-agent/prompt-queue.js +3 -0
  26. package/yeaft/sub-agent/result-reference.js +13 -0
  27. package/yeaft/sub-agent/runner.js +11 -6
  28. package/yeaft/sub-agent/task-identity.js +46 -0
  29. package/yeaft/tools/agent.js +73 -2
  30. package/yeaft/tools/history-search.js +28 -8
  31. package/yeaft/tools/types.js +2 -0
  32. package/yeaft/tools/wait-agent.js +21 -1
  33. package/yeaft/tools/web-fetch.js +9 -0
  34. package/yeaft/web-bridge.js +25 -2
  35. package/yeaft/work-center/runner.js +1 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Query-local loop evidence. Activity is not completion: only changed observations
3
+ * (including new negative reads) count as evidence. No transcript, arguments or
4
+ * result bodies escape this object. Polling/waiting is neutral, not stagnation or progress.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { argsHashOf } from './tool-folding/exec-log.js';
8
+
9
+ const MAX_ENTRIES = 1024;
10
+ const WARNING_BATCHES = 4;
11
+ const RECOVERY_BATCHES = 8;
12
+ const REPORT_BATCHES = 12;
13
+ const FAILURE_LIMIT = 3;
14
+ const NEUTRAL_TOOLS = new Set([
15
+ 'AskUser', 'WaitAgent', 'ListAgents', 'WaitTask', 'ListTasks', 'ReadTaskLog',
16
+ 'PromptAgent', 'UpdateAgent', 'CloseAgent', 'PresentResponse',
17
+ ]);
18
+
19
+ function boundedSet(map, key, value) {
20
+ map.delete(key);
21
+ map.set(key, value);
22
+ if (map.size > MAX_ENTRIES) map.delete(map.keys().next().value);
23
+ }
24
+ function resultHash(output) {
25
+ return createHash('sha256').update(typeof output === 'string' ? output : JSON.stringify(output) || '').digest('hex');
26
+ }
27
+
28
+ function failureKind(output, call, readOnly) {
29
+ const text = typeof output === 'string' ? output : JSON.stringify(output) || '';
30
+ const errorText = text.replace(/^Error:\s*/, '');
31
+ let contract;
32
+ // MCP and legacy tools throw JSON envelopes; Engine prefixes their messages
33
+ // with "Error: ". Preserve that contract for classification without changing
34
+ // the raw persisted/UI tool output.
35
+ try { contract = typeof output === 'string' ? JSON.parse(errorText) : output; } catch { /* legacy text errors */ }
36
+ // Confirmed nonzero exit is not rollback evidence. It is, however, a finished
37
+ // command that can be revalidated after an edit or new input, unlike a timeout
38
+ // or an operation whose lifecycle is still unknown.
39
+ if (call.name === 'Bash' && contract?.code === 'bash_exit_nonzero'
40
+ && contract.failureType === 'exit_nonzero' && contract.terminationConfirmed === true
41
+ && contract.timedOut === false && Number.isInteger(contract.exitCode)) return 'terminated_command';
42
+ if (contract?.errorEffect === 'unknown' || contract?.replaySafe === false) return 'uncertain';
43
+ // Match the registry's explicit pre-execution validation contract, not words
44
+ // in arbitrary messages or file paths such as /schema/validation/*.json.
45
+ if (contract?.code === 'invalid_arguments' && contract.errorEffect === 'none') return 'validation';
46
+ // A dispatched mutation without an explicit no-effect contract is unsafe to
47
+ // replay regardless of whether its message happens to mention a timeout.
48
+ if (!readOnly && contract?.errorEffect !== 'none') return 'uncertain';
49
+ const message = typeof contract?.error === 'string' ? contract.error : errorText;
50
+ if (['EPERM', 'EACCES'].includes(contract?.code)
51
+ || /^(?:Failed to read file:\s*)?(?:EPERM\b|EACCES\b|permission denied\b|access denied\b|forbidden\b)/i.test(message)) return 'permission';
52
+ // Known negative filesystem observations contain paths, not lifecycle data.
53
+ // Names such as /timeout/schema/ are domain evidence, never timeout signals.
54
+ if (readOnly && (/^(?:File not found:|ENOENT\b|Path is a directory:|Cannot read binary file:|File too large:)/i.test(message)
55
+ || contract?.code === 'ENOENT')) return resultHash(output);
56
+ if (/timed?\s*out|timeout|ECONNRESET|disconnect|socket hang up|connection closed/i.test(message)) return 'uncertain';
57
+ return resultHash(output);
58
+ }
59
+
60
+ export class QueryProgress {
61
+ #results = new Map();
62
+ #mutations = new Map();
63
+ #failures = new Map();
64
+ #batch = { evidence: false, repeated: false, failed: false };
65
+ #revision = 0;
66
+ #state = {
67
+ modelRequests: 0, toolExecutions: 0, reusedCalls: 0, repeatedCalls: 0,
68
+ failedCalls: 0, stage: 'running', reason: null, lastEvidenceAt: null, stagnantBatches: 0,
69
+ };
70
+
71
+ requestStarted() { this.#state.modelRequests += 1; }
72
+
73
+ /** Caller feedback/new owned task results may invalidate prior observations. */
74
+ resetEvidence() {
75
+ this.#results.clear();
76
+ this.#mutations.clear();
77
+ // New instructions/results do not prove a timed-out mutation was rolled back.
78
+ for (const [key, failure] of this.#failures) {
79
+ if (failure.readOnly || failure.kind !== 'uncertain') this.#failures.delete(key);
80
+ }
81
+ this.#state.stagnantBatches = 0;
82
+ this.#state.stage = 'running';
83
+ this.#state.reason = null;
84
+ this.#batch = { evidence: false, repeated: false, failed: false };
85
+ }
86
+
87
+ /** Limit unchanged failures; never automatically replay an uncertain mutation. */
88
+ blocked(call) {
89
+ const failure = this.#failures.get(`${call.name}:${argsHashOf(call.input)}`);
90
+ // A possibly committed mutation must be verified before even the first replay.
91
+ if (!failure || (failure.count < FAILURE_LIMIT
92
+ && !(['uncertain', 'terminated_command'].includes(failure.kind) && !failure.readOnly))) return null;
93
+ return `Blocked unchanged failed call to ${call.name} after ${failure.count} equivalent failures. This call was NOT executed. Use the earlier error to correct the arguments or choose a different supported path. If an earlier operation timed out or disconnected, its side effects may already have happened: verify the target state before retrying. Otherwise report the concrete blocker. Previous failed call: ${failure.callId}.`;
94
+ }
95
+
96
+ observe({ call, output, isError = false, readOnly = false, executed = true, reused = false, skipped = false, policy = 'warn' }) {
97
+ if (skipped) return;
98
+ if (executed) this.#state.toolExecutions += 1;
99
+ if (reused) this.#state.reusedCalls += 1;
100
+ if (isError) this.#state.failedCalls += 1;
101
+ const key = `${call.name}:${argsHashOf(call.input)}`;
102
+ const hash = resultHash(output);
103
+ if (isError) {
104
+ const prior = this.#failures.get(key);
105
+ const kind = failureKind(output, call, readOnly);
106
+ if (executed && !readOnly) {
107
+ // A partial mutation may have changed even a previously unreadable
108
+ // resource. Permit verification, never a replay of the mutation itself.
109
+ this.#results.clear();
110
+ for (const [failedKey, failure] of this.#failures) {
111
+ if (failure.readOnly) this.#failures.delete(failedKey);
112
+ }
113
+ }
114
+ // A blocked result is synthetic; do not replace the original failure.
115
+ if (executed) boundedSet(this.#failures, key, {
116
+ kind, readOnly, count: prior?.kind === kind ? prior.count + 1 : 1, callId: call.id,
117
+ });
118
+ const repeated = !executed || prior?.kind === kind;
119
+ // A first negative observation for a distinct read narrows the search.
120
+ // Validation/permission failures are blockers, not domain evidence.
121
+ if (readOnly && !repeated && !['validation', 'permission', 'uncertain'].includes(kind)) {
122
+ this.#batch.evidence = true;
123
+ this.#state.lastEvidenceAt = Date.now();
124
+ } else {
125
+ this.#batch.failed = true;
126
+ }
127
+ if (repeated) {
128
+ this.#batch.repeated = true;
129
+ this.#state.repeatedCalls += 1;
130
+ }
131
+ return;
132
+ }
133
+ this.#failures.delete(key);
134
+ if (NEUTRAL_TOOLS.has(call.name) || policy === 'allow') return;
135
+ if (!readOnly && executed && !reused) {
136
+ // New mutations invalidate read observations, so edit/verify loops remain
137
+ // useful. An identical mutation/acknowledgement is not itself evidence
138
+ // of another change; never cache or replay it, merely count repetition.
139
+ if (this.#mutations.get(key) === hash) {
140
+ this.#batch.repeated = true;
141
+ this.#state.repeatedCalls += 1;
142
+ } else {
143
+ boundedSet(this.#mutations, key, hash);
144
+ this.#results.clear();
145
+ // Reads and confirmed, finished commands may now succeed after an
146
+ // edit. Unknown/timeout side effects still require explicit verification.
147
+ for (const [failedKey, failure] of this.#failures) {
148
+ if (failure.readOnly || failure.kind === 'terminated_command') this.#failures.delete(failedKey);
149
+ }
150
+ this.#batch.evidence = true;
151
+ this.#state.lastEvidenceAt = Date.now();
152
+ }
153
+ return;
154
+ }
155
+ // Different ranges/pages are independent observations. A changed result for
156
+ // the same call is fresh evidence even when it returns to an older value.
157
+ const prior = this.#results.get(key);
158
+ if (prior?.hash === hash || reused || !executed) {
159
+ this.#state.repeatedCalls += 1;
160
+ this.#batch.repeated = true;
161
+ return readOnly && prior?.hash === hash ? prior.callId : null;
162
+ } else {
163
+ boundedSet(this.#results, key, { hash, callId: call.id });
164
+ this.#batch.evidence = true;
165
+ this.#state.lastEvidenceAt = Date.now();
166
+ }
167
+ }
168
+
169
+ /** Called only AFTER every tool result is paired and persisted. */
170
+ finishBatch() {
171
+ const previousStage = this.#state.stage;
172
+ if (this.#batch.evidence) {
173
+ this.#state.stagnantBatches = 0;
174
+ this.#state.stage = 'running';
175
+ this.#state.reason = null;
176
+ } else if (this.#batch.repeated || this.#batch.failed) {
177
+ this.#state.stagnantBatches += 1;
178
+ this.#state.reason = this.#batch.failed ? 'repeated_failure' : 'repeated_results';
179
+ const count = this.#state.stagnantBatches;
180
+ this.#state.stage = count >= REPORT_BATCHES ? 'stopped'
181
+ : count >= RECOVERY_BATCHES ? 'recovering'
182
+ : count >= WARNING_BATCHES ? 'warning' : 'running';
183
+ }
184
+ this.#batch = { evidence: false, repeated: false, failed: false };
185
+ if (previousStage === this.#state.stage || this.#state.stage === 'running') return null;
186
+ if (this.#state.stage === 'stopped') return 'report';
187
+ return this.#state.stage;
188
+ }
189
+
190
+ snapshot(identity = {}) {
191
+ return { type: 'query_progress', ...identity, revision: ++this.#revision, ...this.#state };
192
+ }
193
+ }
194
+
195
+ export function progressReminder(action, reason) {
196
+ const failureHint = reason === 'repeated_failure'
197
+ ? 'The same execution paths are failing. A timeout does not prove an operation did not happen; verify state before retrying any side effect.'
198
+ : 'Recent tool batches repeat results already available; re-reading or restating a plan is not new evidence.';
199
+ if (action === 'report') return `[system note] Investigation stopped after repeated non-progress batches despite recovery guidance. ${failureHint} Do not claim the user task is complete. Using only existing evidence, give a concise handoff: completed artifacts, unfinished requirements, concrete blocker, and a safe next step. No further tools are available for this one reporting response.`;
200
+ return `[system note] Query progress ${action === 'recovering' ? 'recovery required' : 'warning'}: ${failureHint} Reuse prior results. Execute a concrete next step toward the requested deliverable, obtain genuinely missing evidence, or explain the blocker. Do not spawn another review of the same baseline or search for your own promises. Long-running useful work and legitimate waits remain allowed.`;
201
+ }
@@ -11,6 +11,9 @@ import { snapshotEffortDecision } from '../effort.js';
11
11
  * @param {{ projectSessionIds?: string[], projectLabel?: string, projectInstruction?: string }} projectContext
12
12
  */
13
13
  export function enqueueSubAgentPrompt(agent, prompt, projectContext = {}) {
14
+ // Reserve the original delegation identity, but stop reusing its mutable
15
+ // evidence as soon as follow-up work is accepted (even before it runs).
16
+ agent.delegationSuperseded = true;
14
17
  if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
15
18
  agent.pendingPrompts.push({
16
19
  prompt,
@@ -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' });