minimal-agent 0.18.7 → 0.18.9
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/package.json +1 -1
- package/src/context/persistContext.js +5 -1
- package/src/context/persistContext.ts +5 -2
- package/src/loop.js +75 -25
- package/src/loop.ts +72 -23
- package/src/memory/factLearning.js +3 -3
- package/src/memory/factLearning.ts +3 -7
- package/src/memory/fileStore.js +2 -34
- package/src/memory/fileStore.ts +2 -26
- package/src/memory/jobPolicy.js +21 -13
- package/src/memory/jobPolicy.ts +22 -11
- package/src/memory/learning.js +3 -3
- package/src/memory/learning.ts +3 -3
- package/src/memory/learningModel.js +4 -2
- package/src/memory/learningModel.ts +4 -2
- package/src/prompts/delegation.js +15 -0
- package/src/prompts/delegation.ts +20 -0
- package/src/prompts/policyRegistry.js +7 -10
- package/src/prompts/policyRegistry.ts +9 -10
- package/src/prompts/promptAssembly.js +16 -23
- package/src/prompts/promptAssembly.ts +22 -27
- package/src/prompts/visualPresentation.js +28 -0
- package/src/prompts/visualPresentation.ts +26 -0
- package/src/runtime/agentRuntimeContext.js +3 -0
- package/src/runtime/agentRuntimeContext.ts +4 -0
- package/src/runtime/fileLock.js +75 -0
- package/src/runtime/fileLock.ts +73 -0
- package/src/runtime/finalAnswer.js +4 -2
- package/src/runtime/finalAnswer.ts +6 -2
- package/src/runtime/outputContract.js +17 -0
- package/src/runtime/outputContract.ts +34 -0
- package/src/session/runAgentTurn.js +7 -2
- package/src/session/runAgentTurn.ts +7 -2
- package/src/session/runtimeStore.js +11 -28
- package/src/session/runtimeStore.ts +9 -23
- package/src/tools/agent/agent.js +9 -6
- package/src/tools/agent/agent.ts +9 -5
- package/src/tools/memory/maintenance.js +2 -0
- package/src/tools/memory/maintenance.ts +2 -0
- package/src/tools/memory/memory.js +58 -14
- package/src/tools/memory/memory.ts +57 -13
- package/src/tools/memory/policy.js +16 -1
- package/src/tools/memory/policy.ts +16 -1
- package/src/tools/skill/searchSkills.js +2 -2
- package/src/tools/skill/searchSkills.ts +2 -2
- package/src/tools/skill/skill.js +4 -0
- package/src/tools/skill/skill.ts +3 -0
- package/src/tools/toolPromptGuides.js +4 -3
- package/src/tools/toolPromptGuides.ts +4 -3
- package/src/types.ts +4 -0
- package/src/web/protocol.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.9",
|
|
4
4
|
"description": "Bun-first 的本地 Agent 内核,支持工具、插件、工作流、上下文压缩、长期记忆与 OpenAI 兼容接口;Tauri 桌面宿主和 Node.js npm 发布可复用同一内核",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
|
@@ -91,7 +91,7 @@ export function filterEphemeralMessages(messages) {
|
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
93
|
* 保存当前 messages 为"上下文文件",覆盖之前内容。
|
|
94
|
-
*
|
|
94
|
+
* 自动保存默认尽力写入;显式用户操作使用 strict,在写失败时抛错。
|
|
95
95
|
*
|
|
96
96
|
* v2.x 新增:写盘前过滤掉 _ephemeral=true 的 system 消息(UI 临时提示,
|
|
97
97
|
* 不应进入持久化历史)。
|
|
@@ -116,6 +116,10 @@ export async function saveContext(messages, file, options) {
|
|
|
116
116
|
await rename(temp, target);
|
|
117
117
|
}
|
|
118
118
|
catch (e) {
|
|
119
|
+
// Explicit user mutations must not report success when persistence failed.
|
|
120
|
+
// Ordinary turn autosave retains its existing best-effort behavior.
|
|
121
|
+
if (options?.strict)
|
|
122
|
+
throw e;
|
|
119
123
|
// 不抛(持久化失败不该影响对话本身),但要出声——磁盘满/权限问题时
|
|
120
124
|
// 用户整段会话会静默丢失;与 clearContext 的 archive 失败处理风格对齐。
|
|
121
125
|
try {
|
|
@@ -101,12 +101,12 @@ export function filterEphemeralMessages(messages: Message[]): Message[] {
|
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* 保存当前 messages 为"上下文文件",覆盖之前内容。
|
|
104
|
-
*
|
|
104
|
+
* 自动保存默认尽力写入;显式用户操作使用 strict,在写失败时抛错。
|
|
105
105
|
*
|
|
106
106
|
* v2.x 新增:写盘前过滤掉 _ephemeral=true 的 system 消息(UI 临时提示,
|
|
107
107
|
* 不应进入持久化历史)。
|
|
108
108
|
*/
|
|
109
|
-
export async function saveContext(messages: Message[], file?: string, options?: { replaceDisplayHistory?: boolean }): Promise<void> {
|
|
109
|
+
export async function saveContext(messages: Message[], file?: string, options?: { replaceDisplayHistory?: boolean; strict?: boolean }): Promise<void> {
|
|
110
110
|
const target = file ?? getContextPath();
|
|
111
111
|
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
112
112
|
try {
|
|
@@ -122,6 +122,9 @@ export async function saveContext(messages: Message[], file?: string, options?:
|
|
|
122
122
|
await writeFile(temp, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
|
|
123
123
|
await rename(temp, target);
|
|
124
124
|
} catch (e) {
|
|
125
|
+
// Explicit user mutations must not report success when persistence failed.
|
|
126
|
+
// Ordinary turn autosave retains its existing best-effort behavior.
|
|
127
|
+
if (options?.strict) throw e;
|
|
125
128
|
// 不抛(持久化失败不该影响对话本身),但要出声——磁盘满/权限问题时
|
|
126
129
|
// 用户整段会话会静默丢失;与 clearContext 的 archive 失败处理风格对齐。
|
|
127
130
|
try {
|
package/src/loop.js
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
import crypto from 'node:crypto';
|
|
30
30
|
import { answerPreparationNotice } from './runtime/answerPreparation.js';
|
|
31
31
|
import { finalAnswerMessages } from './runtime/finalAnswer.js';
|
|
32
|
+
import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
|
|
33
|
+
import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
|
|
32
34
|
import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
|
|
33
35
|
import { conciseToolPath } from './runtime/toolDisplay.js';
|
|
34
36
|
import { dirname, resolve } from 'node:path';
|
|
@@ -67,6 +69,7 @@ import { createAgentRuntimeContext, } from './runtime/agentRuntimeContext.js';
|
|
|
67
69
|
import { stripThinkBlocks } from './utils/stripThink.js';
|
|
68
70
|
import { orderedPool } from './runtime/orderedPool.js';
|
|
69
71
|
import { takeAgentNotifications, agentCoordinationContext } from './tools/agent/agentTaskService.js';
|
|
72
|
+
import { delegationRuntimeGuidance } from './prompts/delegation.js';
|
|
70
73
|
import { executionSegments } from './tools/executionSegments.js';
|
|
71
74
|
import { acquireSubagentLease, normalizeWriteScopes } from './tools/agent/coordinator.js';
|
|
72
75
|
const DELIVERY_ARTIFACT = '(?:文件|合同|协议|方案|文档|表格|报告|图片|图像|海报|幻灯片|PPT|PDF|代码|脚本|压缩包|网页|页面|HTML|DOCX|XLSX|CSV|ZIP)';
|
|
@@ -237,7 +240,11 @@ export async function* runQuery(userInput, options) {
|
|
|
237
240
|
let resultPresented = false;
|
|
238
241
|
let presentedResultUnverified = false;
|
|
239
242
|
let deliveryCorrectionAttempted = false;
|
|
243
|
+
let emptyResponseRecoveryAttempted = false;
|
|
244
|
+
let emptyResponseRecoveryReason = '';
|
|
240
245
|
let askCorrectionAttempted = false;
|
|
246
|
+
let diagramPreparationAttempted = false;
|
|
247
|
+
const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
|
|
241
248
|
const subagents = await configService.subagents();
|
|
242
249
|
await hydrateSessionWorktree({
|
|
243
250
|
sessionState,
|
|
@@ -246,6 +253,8 @@ export async function* runQuery(userInput, options) {
|
|
|
246
253
|
cwd: runtimeContext.cwd,
|
|
247
254
|
});
|
|
248
255
|
const toolCallContext = {
|
|
256
|
+
output,
|
|
257
|
+
availableToolNames: () => currentEffectiveToolSet.names,
|
|
249
258
|
executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
|
|
250
259
|
history,
|
|
251
260
|
sessionState,
|
|
@@ -320,8 +329,13 @@ export async function* runQuery(userInput, options) {
|
|
|
320
329
|
const parts = [
|
|
321
330
|
transientFor(activeTools),
|
|
322
331
|
coordinationText,
|
|
332
|
+
delegationRuntimeGuidance({ available: runtimeContext.profile === 'main' && currentEffectiveToolSet.names.has('Agent'),
|
|
333
|
+
maxConcurrent: subagents.maxConcurrent, callsUsed: toolCallContext.agentCalls?.used ?? 0,
|
|
334
|
+
maxCalls: toolCallContext.agentCalls?.max ?? 0 }),
|
|
335
|
+
diagramPreparationAttempted ? visualPresentation(output) : '',
|
|
323
336
|
delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
|
|
324
337
|
askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
|
|
338
|
+
emptyResponseRecoveryAttempted ? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。` : '',
|
|
325
339
|
deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
|
|
326
340
|
advisorAvailable ? advisorCoordinator.transientReminder() : '',
|
|
327
341
|
advisorAvailable
|
|
@@ -685,16 +699,24 @@ export async function* runQuery(userInput, options) {
|
|
|
685
699
|
requiredNames: [...effectiveToolSet.requiredNames],
|
|
686
700
|
});
|
|
687
701
|
advisorCoordinator.setAvailable(currentEffectiveToolSet.names.has('Advisor'));
|
|
702
|
+
// Tool availability may change during emergency fitting. Rebuild dynamic
|
|
703
|
+
// guidance against the actual request boundary, then account for its size.
|
|
704
|
+
requestTransientContext = buildRequestTransientContext();
|
|
705
|
+
lastRequestTransientContext = requestTransientContext;
|
|
706
|
+
const finalRequestBudget = buildContextBudgetPlan({
|
|
707
|
+
provider, history, tools: currentEffectiveToolSet.tools,
|
|
708
|
+
transientContext: requestTransientContext,
|
|
709
|
+
});
|
|
688
710
|
if (emergencyFit.removedNames.length > 0) {
|
|
689
711
|
yield {
|
|
690
712
|
type: 'warning',
|
|
691
713
|
message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
|
|
692
714
|
};
|
|
693
715
|
}
|
|
694
|
-
if (
|
|
716
|
+
if (finalRequestBudget.overBudget) {
|
|
695
717
|
yield {
|
|
696
718
|
type: 'error',
|
|
697
|
-
error: `当前请求即使经过上下文降级仍需要约 ${
|
|
719
|
+
error: `当前请求即使经过上下文降级仍需要约 ${finalRequestBudget.totalInputTokens} tokens,超过模型输入上限 ${finalRequestBudget.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
|
|
698
720
|
code: 'context_overflow',
|
|
699
721
|
};
|
|
700
722
|
yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('error') };
|
|
@@ -737,7 +759,7 @@ export async function* runQuery(userInput, options) {
|
|
|
737
759
|
for await (const ev of chatFn({
|
|
738
760
|
provider,
|
|
739
761
|
messages: modelMessages,
|
|
740
|
-
tools: currentEffectiveToolSet.tools,
|
|
762
|
+
tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
|
|
741
763
|
signal,
|
|
742
764
|
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
743
765
|
promptCache: options.promptCache,
|
|
@@ -896,6 +918,16 @@ export async function* runQuery(userInput, options) {
|
|
|
896
918
|
function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
|
|
897
919
|
assistantText = '';
|
|
898
920
|
}
|
|
921
|
+
// Count the calls that survived review scheduling, including failed loads.
|
|
922
|
+
for (const call of toolCallsByIndex) {
|
|
923
|
+
if (call.function.name !== 'Skill')
|
|
924
|
+
continue;
|
|
925
|
+
try {
|
|
926
|
+
if (JSON.parse(call.function.arguments).name === 'dynamic-ui')
|
|
927
|
+
diagramPreparationAttempted = true;
|
|
928
|
+
}
|
|
929
|
+
catch { /* Ordinary tool validation reports malformed arguments. */ }
|
|
930
|
+
}
|
|
899
931
|
const assistantMsg = {
|
|
900
932
|
role: 'assistant',
|
|
901
933
|
content: assistantText.length > 0 ? assistantText : null,
|
|
@@ -909,6 +941,20 @@ export async function* runQuery(userInput, options) {
|
|
|
909
941
|
};
|
|
910
942
|
// 4. 没有工具调用 → 整轮交互结束
|
|
911
943
|
finalResponse: if (toolCallsByIndex.length === 0) {
|
|
944
|
+
// Repair only private preparation, through the ordinary tool pipeline.
|
|
945
|
+
// One attempt per turn, including missing/failed skills; never rewind a reply.
|
|
946
|
+
if (!diagramPreparationAttempted && !options.responseFormat
|
|
947
|
+
&& runtimeContext.profile === 'main' && output.diagrams.length > 0
|
|
948
|
+
&& currentEffectiveToolSet.names.has('Skill') && turn < maxTurns
|
|
949
|
+
&& needsDiagramPreparation(assistantText, userInput)) {
|
|
950
|
+
diagramPreparationAttempted = true;
|
|
951
|
+
toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
|
|
952
|
+
name: 'Skill', arguments: JSON.stringify({ name: 'dynamic-ui' }),
|
|
953
|
+
} });
|
|
954
|
+
assistantMsg.content = null;
|
|
955
|
+
assistantMsg.tool_calls = toolCallsByIndex;
|
|
956
|
+
break finalResponse;
|
|
957
|
+
}
|
|
912
958
|
if (!askCorrectionAttempted && runtimeContext.interactiveAsk
|
|
913
959
|
&& currentEffectiveToolSet.names.has('AskUserQuestion')
|
|
914
960
|
&& needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
|
|
@@ -968,8 +1014,19 @@ export async function* runQuery(userInput, options) {
|
|
|
968
1014
|
yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
|
|
969
1015
|
}
|
|
970
1016
|
if (emptyResponse) {
|
|
971
|
-
//
|
|
972
|
-
//
|
|
1017
|
+
// Recover once within the existing turn budget, retaining tool receipts and
|
|
1018
|
+
// advisor state. Never replay tools or expose an empty candidate as an answer.
|
|
1019
|
+
if (!resultPresented && !emptyResponseRecoveryAttempted && turn < maxTurns) {
|
|
1020
|
+
emptyResponseRecoveryAttempted = true;
|
|
1021
|
+
emptyResponseRecoveryReason = lastFinishReason === 'length'
|
|
1022
|
+
? '输出额度耗尽' : lastFinishReason === 'unknown'
|
|
1023
|
+
? '响应流缺少正常结束状态' : '响应为空或仅含思考内容';
|
|
1024
|
+
const message = { role: 'system', content: '正在恢复回答,已保留调查结果与工作进展。',
|
|
1025
|
+
id: crypto.randomUUID(), timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
|
|
1026
|
+
history.push(message);
|
|
1027
|
+
yield { type: 'injected_message', message };
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
973
1030
|
yield* retireDraft('empty_response');
|
|
974
1031
|
}
|
|
975
1032
|
else {
|
|
@@ -979,14 +1036,14 @@ export async function* runQuery(userInput, options) {
|
|
|
979
1036
|
let publicationInterrupted = false;
|
|
980
1037
|
let streamed = false;
|
|
981
1038
|
if (!options.responseFormat && runtimeContext.profile === 'main'
|
|
982
|
-
&&
|
|
1039
|
+
&& output.finalDelivery === 'stream') {
|
|
983
1040
|
const candidate = assistantText;
|
|
984
1041
|
const candidateFinishReason = lastFinishReason;
|
|
985
1042
|
let publishedText = '';
|
|
986
1043
|
let publicationDone = false;
|
|
987
1044
|
yield { type: 'stage_change', stage: 'streaming' };
|
|
988
1045
|
try {
|
|
989
|
-
const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
|
|
1046
|
+
const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
|
|
990
1047
|
const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
|
|
991
1048
|
if (publicationPlan.overBudget)
|
|
992
1049
|
throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
|
|
@@ -998,7 +1055,7 @@ export async function* runQuery(userInput, options) {
|
|
|
998
1055
|
for await (const event of chatFn({
|
|
999
1056
|
provider,
|
|
1000
1057
|
messages: publicationMessages,
|
|
1001
|
-
tools:
|
|
1058
|
+
tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
|
|
1002
1059
|
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
1003
1060
|
promptCache: options.promptCache,
|
|
1004
1061
|
})) {
|
|
@@ -1015,11 +1072,8 @@ export async function* runQuery(userInput, options) {
|
|
|
1015
1072
|
break;
|
|
1016
1073
|
}
|
|
1017
1074
|
if (event.type === 'text_delta') {
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
if (!candidate.startsWith(publishedText + event.delta)) {
|
|
1021
|
-
throw new Error('发布内容偏离定稿,使用原定稿。');
|
|
1022
|
-
}
|
|
1075
|
+
// Final expression is live generation, not an exact-copy replay.
|
|
1076
|
+
// Once visible, text is never replaced or joined with a different draft.
|
|
1023
1077
|
publishedText += event.delta;
|
|
1024
1078
|
streamed = true;
|
|
1025
1079
|
yield { type: 'text', delta: event.delta };
|
|
@@ -1040,26 +1094,20 @@ export async function* runQuery(userInput, options) {
|
|
|
1040
1094
|
throw new Error('最终答案流缺少有效的结束状态。');
|
|
1041
1095
|
if (!publishedText.trim() && !publicationInterrupted)
|
|
1042
1096
|
throw new Error('最终表达未返回正文。');
|
|
1043
|
-
if (!publicationInterrupted && publishedText !== candidate)
|
|
1044
|
-
throw new Error('最终表达未完整传递定稿。');
|
|
1045
1097
|
}
|
|
1046
1098
|
catch (error) {
|
|
1047
1099
|
publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || error?.name === 'AbortError';
|
|
1048
1100
|
publicationError = error;
|
|
1049
1101
|
}
|
|
1050
|
-
if (publicationError && !publicationInterrupted) {
|
|
1051
|
-
yield { type: 'warning', message: '
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
yield { type: 'text', delta: remaining };
|
|
1055
|
-
streamed = true;
|
|
1056
|
-
}
|
|
1102
|
+
if (publicationError && !publicationInterrupted && !publishedText) {
|
|
1103
|
+
yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
|
|
1104
|
+
yield { type: 'text', delta: candidate };
|
|
1105
|
+
streamed = true;
|
|
1057
1106
|
publishedText = candidate;
|
|
1107
|
+
lastFinishReason = candidateFinishReason;
|
|
1058
1108
|
publicationError = undefined;
|
|
1059
1109
|
}
|
|
1060
1110
|
assistantText = publishedText;
|
|
1061
|
-
if (!publicationInterrupted)
|
|
1062
|
-
lastFinishReason = candidateFinishReason;
|
|
1063
1111
|
assistantMsg.content = publishedText || null;
|
|
1064
1112
|
if (!publishedText) {
|
|
1065
1113
|
if (publicationInterrupted)
|
|
@@ -1140,7 +1188,9 @@ export async function* runQuery(userInput, options) {
|
|
|
1140
1188
|
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1141
1189
|
};
|
|
1142
1190
|
if (emptyResponse && !resultPresented) {
|
|
1143
|
-
const warning =
|
|
1191
|
+
const warning = emptyResponseRecoveryAttempted
|
|
1192
|
+
? '自动恢复后仍未生成最终答复,已有进展已保留。可以继续本轮或检查模型服务。'
|
|
1193
|
+
: '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
|
|
1144
1194
|
yield { type: 'warning', message: warning };
|
|
1145
1195
|
yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
|
|
1146
1196
|
}
|
package/src/loop.ts
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
import crypto from 'node:crypto';
|
|
31
31
|
import { answerPreparationNotice } from './runtime/answerPreparation.js';
|
|
32
32
|
import { finalAnswerMessages } from './runtime/finalAnswer.js';
|
|
33
|
+
import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
|
|
34
|
+
import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
|
|
33
35
|
import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
|
|
34
36
|
import { conciseToolPath } from './runtime/toolDisplay.js';
|
|
35
37
|
import { dirname, resolve } from 'node:path';
|
|
@@ -111,6 +113,7 @@ import {
|
|
|
111
113
|
import { stripThinkBlocks } from './utils/stripThink.js';
|
|
112
114
|
import { orderedPool } from './runtime/orderedPool.js';
|
|
113
115
|
import { takeAgentNotifications, agentCoordinationContext } from './tools/agent/agentTaskService.js';
|
|
116
|
+
import { delegationRuntimeGuidance } from './prompts/delegation.js';
|
|
114
117
|
import { executionSegments } from './tools/executionSegments.js';
|
|
115
118
|
import { acquireSubagentLease, normalizeWriteScopes } from './tools/agent/coordinator.js';
|
|
116
119
|
|
|
@@ -447,7 +450,11 @@ export async function* runQuery(
|
|
|
447
450
|
let resultPresented = false;
|
|
448
451
|
let presentedResultUnverified = false;
|
|
449
452
|
let deliveryCorrectionAttempted = false;
|
|
453
|
+
let emptyResponseRecoveryAttempted = false;
|
|
454
|
+
let emptyResponseRecoveryReason = '';
|
|
450
455
|
let askCorrectionAttempted = false;
|
|
456
|
+
let diagramPreparationAttempted = false;
|
|
457
|
+
const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
|
|
451
458
|
const subagents = await configService.subagents();
|
|
452
459
|
await hydrateSessionWorktree({
|
|
453
460
|
sessionState,
|
|
@@ -456,6 +463,8 @@ export async function* runQuery(
|
|
|
456
463
|
cwd: runtimeContext.cwd,
|
|
457
464
|
});
|
|
458
465
|
const toolCallContext: ToolCallContext = {
|
|
466
|
+
output,
|
|
467
|
+
availableToolNames: () => currentEffectiveToolSet.names,
|
|
459
468
|
executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
|
|
460
469
|
history,
|
|
461
470
|
sessionState,
|
|
@@ -533,8 +542,13 @@ export async function* runQuery(
|
|
|
533
542
|
const parts = [
|
|
534
543
|
transientFor(activeTools),
|
|
535
544
|
coordinationText,
|
|
545
|
+
delegationRuntimeGuidance({ available: runtimeContext.profile === 'main' && currentEffectiveToolSet.names.has('Agent'),
|
|
546
|
+
maxConcurrent: subagents.maxConcurrent, callsUsed: toolCallContext.agentCalls?.used ?? 0,
|
|
547
|
+
maxCalls: toolCallContext.agentCalls?.max ?? 0 }),
|
|
548
|
+
diagramPreparationAttempted ? visualPresentation(output) : '',
|
|
536
549
|
delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
|
|
537
550
|
askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
|
|
551
|
+
emptyResponseRecoveryAttempted ? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。` : '',
|
|
538
552
|
deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
|
|
539
553
|
advisorAvailable ? advisorCoordinator.transientReminder() : '',
|
|
540
554
|
advisorAvailable
|
|
@@ -910,16 +924,24 @@ export async function* runQuery(
|
|
|
910
924
|
advisorCoordinator.setAvailable(
|
|
911
925
|
currentEffectiveToolSet.names.has('Advisor'),
|
|
912
926
|
);
|
|
927
|
+
// Tool availability may change during emergency fitting. Rebuild dynamic
|
|
928
|
+
// guidance against the actual request boundary, then account for its size.
|
|
929
|
+
requestTransientContext = buildRequestTransientContext();
|
|
930
|
+
lastRequestTransientContext = requestTransientContext;
|
|
931
|
+
const finalRequestBudget = buildContextBudgetPlan({
|
|
932
|
+
provider, history, tools: currentEffectiveToolSet.tools,
|
|
933
|
+
transientContext: requestTransientContext,
|
|
934
|
+
});
|
|
913
935
|
if (emergencyFit.removedNames.length > 0) {
|
|
914
936
|
yield {
|
|
915
937
|
type: 'warning',
|
|
916
938
|
message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
|
|
917
939
|
};
|
|
918
940
|
}
|
|
919
|
-
if (
|
|
941
|
+
if (finalRequestBudget.overBudget) {
|
|
920
942
|
yield {
|
|
921
943
|
type: 'error',
|
|
922
|
-
error: `当前请求即使经过上下文降级仍需要约 ${
|
|
944
|
+
error: `当前请求即使经过上下文降级仍需要约 ${finalRequestBudget.totalInputTokens} tokens,超过模型输入上限 ${finalRequestBudget.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
|
|
923
945
|
code: 'context_overflow',
|
|
924
946
|
};
|
|
925
947
|
yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('error') };
|
|
@@ -966,7 +988,7 @@ export async function* runQuery(
|
|
|
966
988
|
for await (const ev of chatFn({
|
|
967
989
|
provider,
|
|
968
990
|
messages: modelMessages,
|
|
969
|
-
tools: currentEffectiveToolSet.tools,
|
|
991
|
+
tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
|
|
970
992
|
signal,
|
|
971
993
|
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
972
994
|
promptCache: options.promptCache,
|
|
@@ -1124,6 +1146,13 @@ export async function* runQuery(
|
|
|
1124
1146
|
function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
|
|
1125
1147
|
assistantText = '';
|
|
1126
1148
|
}
|
|
1149
|
+
// Count the calls that survived review scheduling, including failed loads.
|
|
1150
|
+
for (const call of toolCallsByIndex) {
|
|
1151
|
+
if (call.function.name !== 'Skill') continue;
|
|
1152
|
+
try {
|
|
1153
|
+
if (JSON.parse(call.function.arguments).name === 'dynamic-ui') diagramPreparationAttempted = true;
|
|
1154
|
+
} catch { /* Ordinary tool validation reports malformed arguments. */ }
|
|
1155
|
+
}
|
|
1127
1156
|
const assistantMsg: Message = {
|
|
1128
1157
|
role: 'assistant',
|
|
1129
1158
|
content: assistantText.length > 0 ? assistantText : null,
|
|
@@ -1137,6 +1166,20 @@ export async function* runQuery(
|
|
|
1137
1166
|
};
|
|
1138
1167
|
// 4. 没有工具调用 → 整轮交互结束
|
|
1139
1168
|
finalResponse: if (toolCallsByIndex.length === 0) {
|
|
1169
|
+
// Repair only private preparation, through the ordinary tool pipeline.
|
|
1170
|
+
// One attempt per turn, including missing/failed skills; never rewind a reply.
|
|
1171
|
+
if (!diagramPreparationAttempted && !options.responseFormat
|
|
1172
|
+
&& runtimeContext.profile === 'main' && output.diagrams.length > 0
|
|
1173
|
+
&& currentEffectiveToolSet.names.has('Skill') && turn < maxTurns
|
|
1174
|
+
&& needsDiagramPreparation(assistantText, userInput)) {
|
|
1175
|
+
diagramPreparationAttempted = true;
|
|
1176
|
+
toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
|
|
1177
|
+
name: 'Skill', arguments: JSON.stringify({ name: 'dynamic-ui' }),
|
|
1178
|
+
} });
|
|
1179
|
+
assistantMsg.content = null;
|
|
1180
|
+
assistantMsg.tool_calls = toolCallsByIndex;
|
|
1181
|
+
break finalResponse;
|
|
1182
|
+
}
|
|
1140
1183
|
if (!askCorrectionAttempted && runtimeContext.interactiveAsk
|
|
1141
1184
|
&& currentEffectiveToolSet.names.has('AskUserQuestion')
|
|
1142
1185
|
&& needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
|
|
@@ -1196,8 +1239,19 @@ export async function* runQuery(
|
|
|
1196
1239
|
if (!deliveryCheckpoint.allow) yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
|
|
1197
1240
|
}
|
|
1198
1241
|
if (emptyResponse) {
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1242
|
+
// Recover once within the existing turn budget, retaining tool receipts and
|
|
1243
|
+
// advisor state. Never replay tools or expose an empty candidate as an answer.
|
|
1244
|
+
if (!resultPresented && !emptyResponseRecoveryAttempted && turn < maxTurns) {
|
|
1245
|
+
emptyResponseRecoveryAttempted = true;
|
|
1246
|
+
emptyResponseRecoveryReason = lastFinishReason === 'length'
|
|
1247
|
+
? '输出额度耗尽' : lastFinishReason === 'unknown'
|
|
1248
|
+
? '响应流缺少正常结束状态' : '响应为空或仅含思考内容';
|
|
1249
|
+
const message: Message = { role: 'system', content: '正在恢复回答,已保留调查结果与工作进展。',
|
|
1250
|
+
id: crypto.randomUUID(), timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
|
|
1251
|
+
history.push(message);
|
|
1252
|
+
yield { type: 'injected_message', message };
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1201
1255
|
yield* retireDraft('empty_response');
|
|
1202
1256
|
} else {
|
|
1203
1257
|
// Publication is a separate tool-free request. Nothing from preparation
|
|
@@ -1206,14 +1260,14 @@ export async function* runQuery(
|
|
|
1206
1260
|
let publicationInterrupted = false;
|
|
1207
1261
|
let streamed = false;
|
|
1208
1262
|
if (!options.responseFormat && runtimeContext.profile === 'main'
|
|
1209
|
-
&&
|
|
1263
|
+
&& output.finalDelivery === 'stream') {
|
|
1210
1264
|
const candidate = assistantText;
|
|
1211
1265
|
const candidateFinishReason = lastFinishReason;
|
|
1212
1266
|
let publishedText = '';
|
|
1213
1267
|
let publicationDone = false;
|
|
1214
1268
|
yield { type: 'stage_change', stage: 'streaming' };
|
|
1215
1269
|
try {
|
|
1216
|
-
const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
|
|
1270
|
+
const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
|
|
1217
1271
|
const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
|
|
1218
1272
|
if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
|
|
1219
1273
|
if (signal?.aborted || options.hasQueuedInput?.()) {
|
|
@@ -1224,7 +1278,7 @@ export async function* runQuery(
|
|
|
1224
1278
|
for await (const event of chatFn({
|
|
1225
1279
|
provider,
|
|
1226
1280
|
messages: publicationMessages,
|
|
1227
|
-
tools:
|
|
1281
|
+
tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
|
|
1228
1282
|
conversationMode: runtimeContext.conversationMode ?? 'private',
|
|
1229
1283
|
promptCache: options.promptCache,
|
|
1230
1284
|
})) {
|
|
@@ -1241,11 +1295,8 @@ export async function* runQuery(
|
|
|
1241
1295
|
break;
|
|
1242
1296
|
}
|
|
1243
1297
|
if (event.type === 'text_delta') {
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
if (!candidate.startsWith(publishedText + event.delta)) {
|
|
1247
|
-
throw new Error('发布内容偏离定稿,使用原定稿。');
|
|
1248
|
-
}
|
|
1298
|
+
// Final expression is live generation, not an exact-copy replay.
|
|
1299
|
+
// Once visible, text is never replaced or joined with a different draft.
|
|
1249
1300
|
publishedText += event.delta;
|
|
1250
1301
|
streamed = true;
|
|
1251
1302
|
yield { type: 'text', delta: event.delta };
|
|
@@ -1260,23 +1311,19 @@ export async function* runQuery(
|
|
|
1260
1311
|
if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
|
|
1261
1312
|
if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
|
|
1262
1313
|
if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
|
|
1263
|
-
if (!publicationInterrupted && publishedText !== candidate) throw new Error('最终表达未完整传递定稿。');
|
|
1264
1314
|
} catch (error) {
|
|
1265
1315
|
publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
|
|
1266
1316
|
publicationError = error;
|
|
1267
1317
|
}
|
|
1268
|
-
if (publicationError && !publicationInterrupted) {
|
|
1269
|
-
yield { type: 'warning', message: '
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
yield { type: 'text', delta: remaining };
|
|
1273
|
-
streamed = true;
|
|
1274
|
-
}
|
|
1318
|
+
if (publicationError && !publicationInterrupted && !publishedText) {
|
|
1319
|
+
yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
|
|
1320
|
+
yield { type: 'text', delta: candidate };
|
|
1321
|
+
streamed = true;
|
|
1275
1322
|
publishedText = candidate;
|
|
1323
|
+
lastFinishReason = candidateFinishReason;
|
|
1276
1324
|
publicationError = undefined;
|
|
1277
1325
|
}
|
|
1278
1326
|
assistantText = publishedText;
|
|
1279
|
-
if (!publicationInterrupted) lastFinishReason = candidateFinishReason;
|
|
1280
1327
|
assistantMsg.content = publishedText || null;
|
|
1281
1328
|
if (!publishedText) {
|
|
1282
1329
|
if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
|
|
@@ -1354,7 +1401,9 @@ export async function* runQuery(
|
|
|
1354
1401
|
trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
|
|
1355
1402
|
};
|
|
1356
1403
|
if (emptyResponse && !resultPresented) {
|
|
1357
|
-
const warning =
|
|
1404
|
+
const warning = emptyResponseRecoveryAttempted
|
|
1405
|
+
? '自动恢复后仍未生成最终答复,已有进展已保留。可以继续本轮或检查模型服务。'
|
|
1406
|
+
: '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
|
|
1358
1407
|
yield { type: 'warning', message: warning };
|
|
1359
1408
|
yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
|
|
1360
1409
|
} else if (pendingDelegations.length) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* its source material. A durable receipt makes retries idempotent. */
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { parseMemoryProposal } from './proposal.js';
|
|
5
|
-
import { failureSchema, jobEligible, recordJobFailure,
|
|
5
|
+
import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import lockfile from 'proper-lockfile';
|
|
8
8
|
import { z } from 'zod';
|
|
@@ -437,7 +437,7 @@ export function scheduleFactLearning(options) {
|
|
|
437
437
|
void runFactLearning(options)
|
|
438
438
|
.catch((error) => {
|
|
439
439
|
if (!options.signal?.aborted)
|
|
440
|
-
|
|
440
|
+
recordBackgroundLearningFailure(options.workspaceId, 'facts', 'learning');
|
|
441
441
|
})
|
|
442
442
|
.finally(async () => {
|
|
443
443
|
const settings = await memoryService.settings();
|
|
@@ -453,7 +453,7 @@ export function scheduleFactLearning(options) {
|
|
|
453
453
|
if (pending.length)
|
|
454
454
|
wakeAt(directory(options.workspaceId), Math.max(Date.now() + 1000, Math.min(...pending.map((turn) => turn.nextAttemptAt ?? Date.now()))), () => scheduleFactLearning({ ...options, force: false }), options.signal);
|
|
455
455
|
})
|
|
456
|
-
.catch((
|
|
456
|
+
.catch(() => recordBackgroundLearningFailure(options.workspaceId, 'facts', 'scheduling'));
|
|
457
457
|
}
|
|
458
458
|
export async function drainFactLearning() {
|
|
459
459
|
await Promise.allSettled([...running.values()].map((task) => task.promise));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* its source material. A durable receipt makes retries idempotent. */
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { parseMemoryProposal } from './proposal.js';
|
|
5
|
-
import { failureSchema, jobEligible, recordJobFailure,
|
|
5
|
+
import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import lockfile from 'proper-lockfile';
|
|
8
8
|
import { z } from 'zod';
|
|
@@ -574,7 +574,7 @@ export function scheduleFactLearning(
|
|
|
574
574
|
void runFactLearning(options)
|
|
575
575
|
.catch((error) => {
|
|
576
576
|
if (!options.signal?.aborted)
|
|
577
|
-
|
|
577
|
+
recordBackgroundLearningFailure(options.workspaceId, 'facts', 'learning');
|
|
578
578
|
})
|
|
579
579
|
.finally(async () => {
|
|
580
580
|
const settings = await memoryService.settings();
|
|
@@ -604,11 +604,7 @@ export function scheduleFactLearning(
|
|
|
604
604
|
options.signal,
|
|
605
605
|
);
|
|
606
606
|
})
|
|
607
|
-
.catch((
|
|
608
|
-
process.stderr.write(
|
|
609
|
-
`[minimal-agent memory] 恢复调度失败:${error.message}\n`,
|
|
610
|
-
),
|
|
611
|
-
);
|
|
607
|
+
.catch(() => recordBackgroundLearningFailure(options.workspaceId, 'facts', 'scheduling'));
|
|
612
608
|
}
|
|
613
609
|
export async function drainFactLearning() {
|
|
614
610
|
await Promise.allSettled([...running.values()].map((task) => task.promise));
|
package/src/memory/fileStore.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import lockfile from 'proper-lockfile';
|
|
3
2
|
import { chmod, mkdir, open, readFile, rename, rm, stat, } from 'node:fs/promises';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
4
|
+
import { acquireStateFileLock } from '../runtime/fileLock.js';
|
|
6
5
|
import { agentStateDir } from '../context/agentHome.js';
|
|
7
6
|
import { CATEGORY_DIR, MEMORY_ID_PATTERN } from './constants.js';
|
|
8
|
-
const LOCK_STALE_MS = 120_000;
|
|
9
|
-
const LOCK_WAIT_MS = 15_000;
|
|
10
7
|
let mutationQueue = Promise.resolve();
|
|
11
8
|
export function memoryRootDir() {
|
|
12
9
|
return join(agentStateDir(), 'memory');
|
|
@@ -54,36 +51,7 @@ export async function pathExists(path) {
|
|
|
54
51
|
export async function acquireFileLock(path, signal) {
|
|
55
52
|
signal?.throwIfAborted();
|
|
56
53
|
await ensurePrivateDir(dirname(path));
|
|
57
|
-
|
|
58
|
-
while (true) {
|
|
59
|
-
signal?.throwIfAborted();
|
|
60
|
-
try {
|
|
61
|
-
// Do not steal old-version file locks. Restart old hosts before upgrading;
|
|
62
|
-
// their locks must be reviewed rather than aged out under a live writer.
|
|
63
|
-
const legacy = await stat(`${path}.lock`).catch(error => {
|
|
64
|
-
if (error.code === 'ENOENT')
|
|
65
|
-
return null;
|
|
66
|
-
throw error;
|
|
67
|
-
});
|
|
68
|
-
if (legacy?.isFile())
|
|
69
|
-
throw new Error('MEMORY_LEGACY_LOCK:请停止旧版本宿主并检查遗留记忆锁');
|
|
70
|
-
const release = await lockfile.lock(path, {
|
|
71
|
-
realpath: false, retries: 0, stale: LOCK_STALE_MS, update: 10_000,
|
|
72
|
-
});
|
|
73
|
-
if (signal?.aborted) {
|
|
74
|
-
await release();
|
|
75
|
-
signal.throwIfAborted();
|
|
76
|
-
}
|
|
77
|
-
return release;
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
if (error.code !== 'ELOCKED')
|
|
81
|
-
throw error;
|
|
82
|
-
if (Date.now() >= deadline)
|
|
83
|
-
throw new Error('等待记忆文件锁超时');
|
|
84
|
-
await delay(40, undefined, { signal });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
54
|
+
return acquireStateFileLock(path, { signal, label: 'MEMORY' });
|
|
87
55
|
}
|
|
88
56
|
export async function atomicWriteFile(path, content) {
|
|
89
57
|
await ensurePrivateDir(dirname(path));
|