@yeaft/webchat-agent 1.0.568 → 1.0.570

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.
@@ -242,6 +242,35 @@ export function truncateToolResultIfNeeded(output, { toolName, language } = {})
242
242
  const originalBytes = Buffer.byteLength(text, 'utf8');
243
243
  if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
244
244
 
245
+ // Preserve the error contract in the model projection; raw tool output
246
+ // remains available to tracing/persistence before this budget is applied.
247
+ if (toolName === 'Bash') {
248
+ const failure = parseToolErrorOutput(text);
249
+ if (failure && failure.code?.startsWith('bash_')) {
250
+ const projected = { ...failure, truncated: true, originalBytes };
251
+ for (const [key, value] of Object.entries(projected)) {
252
+ if (key !== 'output' && typeof value === 'string') projected[key] = truncateUtf8(value, 1024);
253
+ }
254
+ const source = typeof failure.output === 'string' ? failure.output : '';
255
+ const buffer = Buffer.from(source, 'utf8');
256
+ const sample = bytes => {
257
+ const half = Math.floor(bytes / 2);
258
+ let start = Math.max(0, buffer.length - half);
259
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
260
+ return truncateUtf8(source, half) + '\n[Middle omitted by tool result budget]\n' + buffer.subarray(start).toString('utf8');
261
+ };
262
+ let low = 0, high = Math.min(buffer.length, TOOL_RESULT_MAX_BYTES);
263
+ while (low < high) {
264
+ const mid = Math.ceil((low + high) / 2);
265
+ projected.output = sample(mid);
266
+ if (Buffer.byteLength(JSON.stringify(projected), 'utf8') <= TOOL_RESULT_MAX_BYTES) low = mid;
267
+ else high = mid - 1;
268
+ }
269
+ projected.output = sample(low);
270
+ return JSON.stringify(projected);
271
+ }
272
+ }
273
+
245
274
  const markerFor = name => normalizeLanguage(language) === 'zh'
246
275
  ? `\n\n[已截断:${name} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 ${formatSize(TOOL_RESULT_MAX_BYTES)},模型消息历史不会看到剩余内容]`
247
276
  : `\n\n[truncated: ${name} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded ${formatSize(TOOL_RESULT_MAX_BYTES)}, the model message history will not see the rest]`;
@@ -9,8 +9,8 @@ import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
9
9
  export default defineTool({
10
10
  name: 'UpdateAgent',
11
11
  description: {
12
- en: 'Adjust a live child in place after inspecting its progress: absolute lifetime time/tool/LLM budgets and explicit extra tool grants. Does not queue a prompt, reset usage, or revive a terminal/reporting child. Give evidence and the remaining task in reason; do not extend stalled or repeating work blindly. Bash permits arbitrary shell and writes, not a read-only sandbox; grant only necessary parent tools and isolate writable workspaces. Already dispatched work is not undone by revocation.',
13
- zh: '检查进展后原地调整活跃子 Agent:累计时间/工具/LLM 上限及额外工具授权。不排队提示、不清零用量、不复活终止或收尾中的任务。reason 说明已有证据和剩余工作,勿盲目给停滞/重复任务扩额。Bash 可执行任意 Shell 和写入,并非只读沙箱;只授予必要的父级工具,写任务隔离 workspace。撤销不撤回已执行操作。',
12
+ en: 'Adjust a live child in place after inspecting its progress: absolute lifetime budgets, extra tool grants, or request a cooperative evidence-only final report. Finalization is control, not a prompt containing reason. Does not reset usage or revive a terminal/reporting child. Already dispatched work is not undone.',
13
+ zh: '检查进展后原地调整活跃子 Agent:累计预算、额外工具授权,或请求基于现有证据协作收尾。收尾是控制信号,不会把 reason 冒充提示词。不清零用量、不复活终止/报告中的任务,也不撤回已派发操作。',
14
14
  },
15
15
  parameters: {
16
16
  type: 'object',
@@ -32,6 +32,10 @@ export default defineTool({
32
32
  type: 'array', items: { type: 'string' }, maxItems: 32,
33
33
  description: { en: 'Replace extra persona grants with these canonical parent tool names (e.g. Bash, FileEdit); [] revokes extras, omission leaves grants unchanged', zh: '替换 persona 额外授权(如 Bash、FileEdit);[] 撤销额外授权,省略则不变' },
34
34
  },
35
+ request_finalize: {
36
+ type: 'boolean',
37
+ description: { en: 'Request one tool-free final report from existing evidence, then end the child lifecycle normally', zh: '请求仅基于现有证据生成一次无工具最终报告,然后正常结束子任务生命周期' },
38
+ },
35
39
  },
36
40
  required: ['agent_id', 'reason'],
37
41
  },
@@ -42,10 +46,10 @@ export default defineTool({
42
46
  const fail = error => JSON.stringify({ error, next_steps: 'Inspect the current agent state and correct the request; do not respawn or repeat blindly.' });
43
47
  const agent = getAgentRegistry().get(input.agent_id);
44
48
  if (!agent || !agentBelongsToCaller(agent, ctx)) return fail(`Agent not found: ${input.agent_id}`);
45
- if (isTerminalAgentStatus(agent.status) || agent.budgetReportStarted || agent.budgetStopReason
49
+ if (isTerminalAgentStatus(agent.status) || agent.budgetReportStarted || agent.finalizationRequested || agent.budgetStopReason
46
50
  || agent.abortController?.signal.aborted) return fail('Agent is terminal, stopping or already reporting; it cannot be extended');
47
51
  if (typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 2000) return fail('reason must contain 1..2000 characters of evidence and remaining work');
48
- if (input.budget === undefined && input.allow_tools === undefined) return fail('budget or allow_tools is required');
52
+ if (input.budget === undefined && input.allow_tools === undefined && input.request_finalize !== true) return fail('budget, allow_tools, or request_finalize=true is required');
49
53
  if (input.budget !== undefined) {
50
54
  const error = validateBudget(input.budget);
51
55
  if (error) return fail(error);
@@ -58,6 +62,7 @@ export default defineTool({
58
62
  // All validation precedes mutation. Original usage/deadline origin are retained.
59
63
  agent.budget = { ...agent.budget, ...input.budget };
60
64
  if (grants) agent.allowTools = grants.tools;
65
+ if (input.request_finalize === true) agent.finalizationRequested = true;
61
66
  agent.controlRevision = (agent.controlRevision || 0) + 1;
62
67
  if (agent.execution && (agent.budget.max_tool_calls === undefined
63
68
  || agent.execution.toolCalls < agent.budget.max_tool_calls * 0.75)) agent.execution.warning = null;
@@ -68,6 +73,7 @@ export default defineTool({
68
73
  agent.refreshToolPolicy?.();
69
74
  agent.rearmWallTimeWatchdog?.();
70
75
  const event = { type: 'sub_agent_control_updated', at: Date.now(), reason: input.reason.trim(),
76
+ requestFinalize: input.request_finalize === true,
71
77
  previousBudget, budget: { ...agent.budget }, previousTools, allowTools: [...(agent.allowTools || [])] };
72
78
  agent.diagnostics ||= [];
73
79
  agent.diagnostics.push(event);
@@ -75,6 +81,9 @@ export default defineTool({
75
81
  try { agent.outputLog?.write(event); } catch { /* diagnostics must not fail the applied update */ }
76
82
  return JSON.stringify({ success: true, agentId: agent.id, status: agent.status,
77
83
  budget: agent.budget, allow_tools: agent.allowTools || [], liveness: diagnoseAgentLiveness(agent),
78
- next_steps: 'Adjustment applied without restarting work. Continue the parent task; use PromptAgent only if new guidance is needed, then collect its reply. Revocation does not cancel already dispatched work.' });
84
+ finalizationRequested: agent.finalizationRequested === true,
85
+ next_steps: input.request_finalize === true
86
+ ? 'Cooperative wrap-up requested without turning reason into a child prompt. Use WaitAgent to collect the evidence-only final report; already dispatched work may finish first.'
87
+ : 'Adjustment applied without restarting work. Continue the parent task; use PromptAgent only if new guidance is needed, then collect its reply. Revocation does not cancel already dispatched work.' });
79
88
  },
80
89
  });
@@ -35,6 +35,7 @@ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
35
35
  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
+ import { describeAgentLifecycle, describeAgentOutcome } from '../sub-agent/outcome.js';
38
39
 
39
40
  /**
40
41
  * Build the status-specific next-step guidance the LLM reads after a wait.
@@ -55,6 +56,9 @@ function nextStepsFor(status, opts = {}) {
55
56
  'as an ordinary successful completion.'
56
57
  );
57
58
  }
59
+ if (opts.incomplete) {
60
+ return 'Sub-agent lifecycle ended with incomplete evidence. Inspect outcome and final_report; do not present partial verdict text as a completed review.';
61
+ }
58
62
  if (opts.timedOut && opts.stale) {
59
63
  return (
60
64
  'No observable event arrived within the diagnostic threshold. This does ' +
@@ -157,12 +161,15 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
157
161
  next_steps: nextStepsFor(status, {
158
162
  timedOut,
159
163
  budgetExceeded: !!budgetResult,
164
+ incomplete: isTerminalAgentStatus(status) && !describeAgentOutcome(agent).complete,
160
165
  stale: liveness.stale,
161
166
  mustCollectReply: mustCollectReply && !liveness.stale,
162
167
  }),
163
168
  agentId: agent.id,
164
169
  name: agent.name,
165
170
  status,
171
+ lifecycle: describeAgentLifecycle(agent),
172
+ outcome: describeAgentOutcome(agent),
166
173
  error: agent.error || null,
167
174
  outputFile: agent.outputFile || null,
168
175
  liveness,
@@ -185,8 +192,17 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
185
192
  env.budget_status = budgetResult.status;
186
193
  env.budget_reason = budgetResult.reason || null;
187
194
  env.partial_output = budgetResult.partial_output || '';
195
+ env.incomplete = true;
196
+ env.truncated = Boolean(budgetResult.truncated || budgetResult.final_report?.truncated);
197
+ env.final_report = budgetResult.final_report || null;
188
198
  env.budget_usage = budgetResult.usage || null;
189
199
  }
200
+ if (agent.finalizationRequested) {
201
+ env.incomplete = true;
202
+ env.final_report = agent.finalReport || null;
203
+ env.truncated = Boolean(agent.finalReport?.truncated);
204
+ env.partial_output = resultText;
205
+ }
190
206
  env.result = resultText;
191
207
  return env;
192
208
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * wait-task.js — Bounded wait for one Session background task.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+ import { isTerminalTaskStatus } from '../tasks/store.js';
7
+
8
+ const DEFAULT_TIMEOUT_MS = 120_000;
9
+ const MAX_TIMEOUT_MS = 600_000;
10
+
11
+ export function taskWaitResult(result) {
12
+ if (!result?.ok) return { error: result?.error || 'Unable to wait for task' };
13
+ const task = result.task;
14
+ if (!task) return { error: 'Task state unavailable' };
15
+ const log = task.log && typeof task.log === 'object' ? task.log : {};
16
+ const taskResult = task.result && typeof task.result === 'object' ? task.result : {};
17
+ return {
18
+ taskId: task.id,
19
+ status: task.status,
20
+ terminal: isTerminalTaskStatus(task.status),
21
+ timedOut: result.timedOut === true,
22
+ exitCode: Number.isInteger(taskResult.exitCode) ? taskResult.exitCode : null,
23
+ signal: taskResult.signal || null,
24
+ resultDelivery: task.resultDelivery,
25
+ resultConsumed: false,
26
+ ...(task.runtime?.subAgentId ? { agentId: task.runtime.subAgentId } : {}),
27
+ ...(task.runtime?.cancelRequestedAt ? { cancelPending: !isTerminalTaskStatus(task.status) } : {}),
28
+ next_steps: 'Status only; no output was consumed. ReadTaskLog with your last read offset (or tail) for evidence; use WaitAgent for a child result. Do not use log.endOffset as an already-read cursor.',
29
+ error: taskResult.error || null,
30
+ log: {
31
+ ...(log.path ? { path: log.path } : {}),
32
+ bytes: Number.isFinite(log.bytes) ? log.bytes : 0,
33
+ endOffset: Number.isFinite(log.bytes) ? log.bytes : 0,
34
+ },
35
+ };
36
+ }
37
+
38
+ export default defineTool({
39
+ name: 'WaitTask',
40
+ description: {
41
+ en: 'Wait for one background task by taskId, up to a bounded timeout. Returns terminal status and a log cursor/reference, never the whole log. This does not cancel or retry the task.',
42
+ zh: '按 taskId 有界等待一个后台任务。仅返回终态和日志游标/引用,不返回完整日志;不会取消或重试任务。',
43
+ },
44
+ parameters: {
45
+ type: 'object',
46
+ properties: {
47
+ taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
48
+ sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
49
+ timeout_ms: { type: 'number', description: { en: `Wait timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})`, zh: `等待超时毫秒数(默认 ${DEFAULT_TIMEOUT_MS},最大 ${MAX_TIMEOUT_MS})` } },
50
+ },
51
+ required: ['taskId'],
52
+ },
53
+ timeoutMs: 0,
54
+ isConcurrencySafe: () => true,
55
+ isReadOnly: () => true,
56
+ cacheWithinQuery: false,
57
+ duplicateCallPolicy: () => 'allow',
58
+ async execute(input = {}, ctx = {}) {
59
+ if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
60
+ if (ctx.sessionId && input.sessionId && input.sessionId !== ctx.sessionId) {
61
+ return JSON.stringify({ error: 'Task access is limited to the current Session', errorEffect: 'none' });
62
+ }
63
+ if (!input.taskId) return JSON.stringify({ error: 'taskId is required' });
64
+ const sessionId = input.sessionId || ctx.sessionId || 'default';
65
+ const timeoutMs = Math.min(Math.max(Number.isFinite(input.timeout_ms) ? input.timeout_ms : DEFAULT_TIMEOUT_MS, 0), MAX_TIMEOUT_MS);
66
+ const result = await ctx.taskManager.waitForTask(sessionId, input.taskId, {
67
+ timeoutMs,
68
+ signal: ctx.signal || null,
69
+ ownerVpId: ctx.currentVpId || null,
70
+ });
71
+ return JSON.stringify(taskWaitResult(result), null, 2);
72
+ },
73
+ });