minimal-agent 0.18.13 → 0.18.14

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 (174) hide show
  1. package/package.json +1 -3
  2. package/plugins/workflow-runner/src/runtime/context.js +1 -1
  3. package/plugins/workflow-runner/src/runtime/context.ts +1 -1
  4. package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
  5. package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
  6. package/skills/ssh-remote-dev/SKILL.md +61 -0
  7. package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
  8. package/src/advisor/README.md +62 -24
  9. package/src/advisor/advisorNudge.js +199 -0
  10. package/src/advisor/advisorNudge.ts +232 -0
  11. package/src/advisor/deliveryReview.js +35 -12
  12. package/src/advisor/deliveryReview.ts +35 -11
  13. package/src/advisor/presentation.js +7 -10
  14. package/src/advisor/presentation.ts +10 -13
  15. package/src/advisor/reviewContext.js +22 -58
  16. package/src/advisor/reviewContext.ts +24 -67
  17. package/src/cli/configMenu.js +17 -128
  18. package/src/cli/configMenu.tsx +17 -193
  19. package/src/cli/print.js +2 -0
  20. package/src/cli/print.ts +2 -0
  21. package/src/cli/streamJson.js +2 -0
  22. package/src/cli/streamJson.ts +2 -0
  23. package/src/cli/wizards/advisorWizard.js +9 -7
  24. package/src/cli/wizards/advisorWizard.tsx +9 -7
  25. package/src/cli/wizards/contextAdvisorWizard.js +9 -7
  26. package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
  27. package/src/cli/wizards/providerPresets.js +27 -1
  28. package/src/cli/wizards/providerPresets.ts +44 -2
  29. package/src/cli/wizards/providerWizard.js +51 -10
  30. package/src/cli/wizards/providerWizard.tsx +59 -9
  31. package/src/config/configDefaults.js +1 -1
  32. package/src/config/configDefaults.ts +1 -1
  33. package/src/config/configDiagnostics.js +1 -21
  34. package/src/config/configDiagnostics.ts +3 -23
  35. package/src/config/configFile.js +11 -4
  36. package/src/config/configFile.ts +27 -7
  37. package/src/config/configManagement.js +1 -0
  38. package/src/config/configManagement.ts +2 -0
  39. package/src/config/configService.js +12 -8
  40. package/src/config/configService.ts +16 -11
  41. package/src/config/numericLimits.js +0 -4
  42. package/src/config/numericLimits.ts +0 -4
  43. package/src/config/reasoningEffort.js +16 -0
  44. package/src/config/reasoningEffort.ts +18 -0
  45. package/src/context/budgetPlan.js +1 -1
  46. package/src/context/budgetPlan.ts +1 -1
  47. package/src/context/optimizeConversation.js +1 -1
  48. package/src/context/optimizeConversation.ts +1 -1
  49. package/src/harness/stallGuard.js +8 -13
  50. package/src/harness/stallGuard.ts +8 -12
  51. package/src/llm/client.js +64 -17
  52. package/src/llm/client.ts +62 -7
  53. package/src/llm/providers/openaiFormat.js +14 -3
  54. package/src/llm/providers/openaiFormat.ts +17 -3
  55. package/src/llm/providers/responsesFormat.js +275 -0
  56. package/src/llm/providers/responsesFormat.ts +306 -0
  57. package/src/loop/README.md +18 -3
  58. package/src/loop/liveDiagramGate.js +63 -0
  59. package/src/loop/liveDiagramGate.ts +61 -0
  60. package/src/loop/messages.js +12 -0
  61. package/src/loop/messages.ts +13 -0
  62. package/src/loop/options.ts +7 -2
  63. package/src/loop/toolBatch.js +0 -1
  64. package/src/loop/toolBatch.ts +0 -1
  65. package/src/loop/toolExecutor.js +11 -46
  66. package/src/loop/toolExecutor.ts +12 -55
  67. package/src/loop/toolPresentation.js +2 -13
  68. package/src/loop/toolPresentation.ts +2 -13
  69. package/src/loop/toolSemantics.js +3 -4
  70. package/src/loop/toolSemantics.ts +4 -6
  71. package/src/loop.js +222 -214
  72. package/src/loop.ts +226 -232
  73. package/src/memory/dependencies.js +11 -3
  74. package/src/memory/dependencies.ts +11 -3
  75. package/src/memory/episodicMemory.js +59 -18
  76. package/src/memory/episodicMemory.ts +58 -18
  77. package/src/memory/experienceStore.js +4 -3
  78. package/src/memory/experienceStore.ts +4 -3
  79. package/src/memory/factCandidates.js +53 -1
  80. package/src/memory/factCandidates.ts +54 -1
  81. package/src/memory/factLearning.js +2 -2
  82. package/src/memory/factLearning.ts +2 -2
  83. package/src/memory/jobPolicy.js +24 -5
  84. package/src/memory/jobPolicy.ts +25 -5
  85. package/src/memory/learning.js +2 -2
  86. package/src/memory/learning.ts +2 -2
  87. package/src/memory/learningModel.js +7 -0
  88. package/src/memory/learningModel.ts +7 -0
  89. package/src/memory/managementService.js +8 -7
  90. package/src/memory/managementService.ts +16 -8
  91. package/src/memory/memoryService.js +104 -33
  92. package/src/memory/memoryService.ts +132 -28
  93. package/src/memory/projectKnowledge.js +39 -3
  94. package/src/memory/projectKnowledge.ts +45 -3
  95. package/src/memory/recallQuery.js +56 -0
  96. package/src/memory/recallQuery.ts +58 -0
  97. package/src/memory/retrieval.js +39 -10
  98. package/src/memory/retrieval.ts +46 -11
  99. package/src/memory/turnJournal.js +75 -10
  100. package/src/memory/turnJournal.ts +66 -10
  101. package/src/memory/types.ts +7 -1
  102. package/src/prompts/delegation.js +2 -1
  103. package/src/prompts/delegation.ts +3 -1
  104. package/src/prompts/policyRegistry.js +9 -12
  105. package/src/prompts/policyRegistry.ts +9 -12
  106. package/src/prompts/promptAssembly.js +6 -5
  107. package/src/prompts/promptAssembly.ts +8 -6
  108. package/src/prompts/visualPresentation.js +39 -10
  109. package/src/prompts/visualPresentation.ts +38 -9
  110. package/src/runtime/answerPreparation.js +1 -1
  111. package/src/runtime/answerPreparation.ts +1 -1
  112. package/src/runtime/fileLock.js +19 -3
  113. package/src/runtime/fileLock.ts +19 -3
  114. package/src/runtime/invocationPolicy.js +1 -1
  115. package/src/runtime/invocationPolicy.ts +1 -1
  116. package/src/runtime/outputContract.js +0 -4
  117. package/src/runtime/outputContract.ts +0 -7
  118. package/src/session/runAgentTurn.js +3 -0
  119. package/src/session/runAgentTurn.ts +3 -0
  120. package/src/team/onlineToolContract.js +0 -1
  121. package/src/team/onlineToolContract.ts +0 -1
  122. package/src/tools/advisor/advisor.js +207 -365
  123. package/src/tools/advisor/advisor.ts +211 -383
  124. package/src/tools/agent/agentTypes.js +0 -1
  125. package/src/tools/agent/agentTypes.ts +0 -1
  126. package/src/tools/agent/coordinator.js +4 -1
  127. package/src/tools/agent/coordinator.ts +4 -1
  128. package/src/tools/bash/semantics.js +4 -5
  129. package/src/tools/bash/semantics.ts +4 -5
  130. package/src/tools/capabilities.js +2 -3
  131. package/src/tools/capabilities.ts +2 -4
  132. package/src/tools/index.js +0 -16
  133. package/src/tools/index.ts +0 -16
  134. package/src/tools/memory/maintenance.js +8 -9
  135. package/src/tools/memory/maintenance.ts +13 -11
  136. package/src/tools/memory/memory.js +5 -1
  137. package/src/tools/memory/memory.ts +5 -0
  138. package/src/tools/optimizeConversation/README.md +4 -6
  139. package/src/tools/toolPromptGuides.js +2 -9
  140. package/src/tools/toolPromptGuides.ts +2 -9
  141. package/src/types.ts +28 -105
  142. package/src/ui/App.js +2 -0
  143. package/src/ui/App.tsx +1 -0
  144. package/src/ui/MessageList.js +8 -44
  145. package/src/ui/MessageList.tsx +3 -81
  146. package/src/ui/hooks/useChat.js +2 -0
  147. package/src/ui/hooks/useChat.ts +2 -0
  148. package/src/ui/toolProgress.js +1 -1
  149. package/src/ui/toolProgress.ts +1 -1
  150. package/src/web/protocol.ts +10 -3
  151. package/src/advisor/advisorCoordinator.js +0 -734
  152. package/src/advisor/advisorCoordinator.ts +0 -825
  153. package/src/cli/wizards/sshHostWizard.js +0 -156
  154. package/src/cli/wizards/sshHostWizard.tsx +0 -214
  155. package/src/context/sshHosts.js +0 -290
  156. package/src/context/sshHosts.ts +0 -312
  157. package/src/loop/publication.js +0 -94
  158. package/src/loop/publication.ts +0 -124
  159. package/src/runtime/finalAnswer.js +0 -9
  160. package/src/runtime/finalAnswer.ts +0 -13
  161. package/src/tools/advisor/advisorDecision.js +0 -79
  162. package/src/tools/advisor/advisorDecision.ts +0 -92
  163. package/src/tools/ssh/ssh.js +0 -152
  164. package/src/tools/ssh/ssh.ts +0 -167
  165. package/src/tools/ssh/sshAvailability.js +0 -35
  166. package/src/tools/ssh/sshAvailability.ts +0 -39
  167. package/src/tools/ssh/sshClient.js +0 -177
  168. package/src/tools/ssh/sshClient.ts +0 -189
  169. package/src/tools/ssh/sshDownload.js +0 -77
  170. package/src/tools/ssh/sshDownload.ts +0 -92
  171. package/src/tools/ssh/sshHostsTool.js +0 -36
  172. package/src/tools/ssh/sshHostsTool.ts +0 -48
  173. package/src/tools/ssh/sshUpload.js +0 -72
  174. package/src/tools/ssh/sshUpload.ts +0 -85
@@ -0,0 +1,306 @@
1
+ /**
2
+ * ============================================================
3
+ * src/llm/providers/responsesFormat.ts —— OpenAI Responses API 协议
4
+ * ------------------------------------------------------------
5
+ * ⚠️ 未经真实端点验证:本机没有可用于付费调用的 OpenAI key,请求体与 SSE 事件字段
6
+ * 照官方文档(POST /v1/responses,stream:true)实现,配了手写的 SSE 录像单测,
7
+ * 没有做过真实 round-trip。拿到 key 后先跑一次真实对话冒烟(含一次工具调用)。
8
+ *
9
+ * 与 openaiFormat.ts / anthropicFormat.ts 同一分工:这里只做「内部 Message[]/Tool[]
10
+ * ⇄ Responses 协议形状」的翻译 + SSE 逐帧解析;重试/退避/空闲看门狗/就地退化
11
+ * 留在 client.ts。
12
+ *
13
+ * 设计取舍(都是为了让它在不依赖服务端会话状态的前提下正确):
14
+ * - 无状态:`store:false`,每次请求重发完整历史,不用 previous_response_id。
15
+ * 与本项目「history 是唯一事实源、压缩/快照在本地」的前提一致。
16
+ * - 工具 `strict:false`:Responses 的函数工具默认 strict:true,要求
17
+ * additionalProperties:false 且全字段 required;本项目的工具 schema 不满足,
18
+ * 不显式关掉就会整批 400。
19
+ * - 回传的 function_call 只带 call_id/name/arguments,**不带 id**:带上 fc_ id
20
+ * 而缺少配对的 reasoning 项,服务端会报错;无状态重发不保存 reasoning 项,
21
+ * 所以只能不带。代价是推理模型在多轮工具调用间丢失上一轮的推理上下文
22
+ * (质量略降,不影响正确性)。
23
+ * - 不请求 reasoning.summary:部分组织需要先做身份验证才能拿推理摘要,
24
+ * 请求了反而 400。服务端若主动发摘要增量,照常显示为思维链。
25
+ * ============================================================
26
+ */
27
+
28
+ import type {
29
+ ChatContentPart,
30
+ LlmStreamEvent,
31
+ LocalProvider,
32
+ Message,
33
+ PromptCacheContext,
34
+ PromptCacheUsage,
35
+ Tool,
36
+ } from '../../types.js';
37
+
38
+ type ResponsesItem = Record<string, unknown>;
39
+
40
+ function userContent(content: string | ChatContentPart[]): unknown {
41
+ if (typeof content === 'string') return content;
42
+ return content.map((part): Record<string, unknown> => {
43
+ if (part.type === 'text') return { type: 'input_text', text: part.text };
44
+ if (part.type === 'image_url') {
45
+ const detail = part.image_url.detail;
46
+ return {
47
+ type: 'input_image',
48
+ image_url: part.image_url.url,
49
+ // 'default' 是本项目内部取值,Responses 只认 auto/low/high。
50
+ detail: detail === 'low' || detail === 'high' ? detail : 'auto',
51
+ };
52
+ }
53
+ return { type: 'input_text', text: '[视频内容未发送:Responses 格式的 provider 暂不支持视频输入]' };
54
+ });
55
+ }
56
+
57
+ /** 内部 Message[](OpenAI Chat 形状)→ Responses 的 input 数组。 */
58
+ export function toResponsesInput(messages: readonly Message[]): ResponsesItem[] {
59
+ const input: ResponsesItem[] = [];
60
+ for (const message of messages) {
61
+ if (message.role === 'system') {
62
+ input.push({ role: 'system', content: message.content });
63
+ } else if (message.role === 'user') {
64
+ input.push({ role: 'user', content: userContent(message.content) });
65
+ } else if (message.role === 'assistant') {
66
+ if (message.content) {
67
+ input.push({ role: 'assistant', content: [{ type: 'output_text', text: message.content }] });
68
+ }
69
+ for (const call of message.tool_calls ?? []) {
70
+ input.push({
71
+ type: 'function_call',
72
+ call_id: call.id,
73
+ name: call.function.name,
74
+ arguments: call.function.arguments || '{}',
75
+ });
76
+ }
77
+ } else {
78
+ input.push({ type: 'function_call_output', call_id: message.tool_call_id, output: message.content });
79
+ }
80
+ }
81
+ return input;
82
+ }
83
+
84
+ function looksLikeVersionedBase(url: string): boolean {
85
+ try {
86
+ const path = new URL(url).pathname.replace(/\/+$/, '');
87
+ if (path === '' || path === '/') return true;
88
+ return /^v\d+/.test(path.split('/').at(-1)!);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** baseURL 是完整端点(以 /responses 结尾);只有「版本化基础路径」才自动补后缀。 */
95
+ export function buildResponsesUrl(provider: LocalProvider): string {
96
+ const url = provider.baseURL.trim().replace(/\/+$/, '');
97
+ if (!url || url.endsWith('/responses')) return url;
98
+ return looksLikeVersionedBase(url) ? `${url}/responses` : url;
99
+ }
100
+
101
+ export interface ResponsesRequestArgs {
102
+ provider: LocalProvider;
103
+ cleanedMessages: readonly Message[];
104
+ tools: Tool[];
105
+ withMaxTokens: boolean;
106
+ withReasoningEffort: boolean;
107
+ withPromptCacheKey?: boolean;
108
+ promptCache?: PromptCacheContext;
109
+ /** 与 Chat Completions 同形状的结构化输出要求,这里翻成 text.format。 */
110
+ responseFormat?: {
111
+ type: 'json_schema';
112
+ json_schema: { name: string; schema: Record<string, unknown>; strict?: boolean };
113
+ };
114
+ withResponseFormat: boolean;
115
+ }
116
+
117
+ export function buildResponsesRequest(args: ResponsesRequestArgs): {
118
+ url: string;
119
+ headers: Record<string, string>;
120
+ body: string;
121
+ } {
122
+ const { provider, cleanedMessages, tools } = args;
123
+ const functionTools = tools.map((tool) => ({
124
+ type: 'function',
125
+ name: tool.name,
126
+ description: typeof tool.description === 'function' ? tool.description() : tool.description,
127
+ parameters: tool.parameters,
128
+ strict: false,
129
+ }));
130
+ const body: Record<string, unknown> = {
131
+ model: provider.model,
132
+ input: toResponsesInput(cleanedMessages),
133
+ stream: true,
134
+ store: false,
135
+ };
136
+ if (functionTools.length > 0) {
137
+ body.tools = functionTools;
138
+ body.tool_choice = 'auto';
139
+ }
140
+ if (args.withMaxTokens && typeof provider.maxTokens === 'number' && provider.maxTokens > 0) {
141
+ body.max_output_tokens = provider.maxTokens;
142
+ }
143
+ if (args.withReasoningEffort && provider.reasoningEffort) {
144
+ body.reasoning = { effort: provider.reasoningEffort };
145
+ }
146
+ if (args.withResponseFormat && args.responseFormat) {
147
+ const schema = args.responseFormat.json_schema;
148
+ body.text = {
149
+ format: {
150
+ type: 'json_schema',
151
+ name: schema.name,
152
+ schema: schema.schema,
153
+ ...(schema.strict !== undefined ? { strict: schema.strict } : {}),
154
+ },
155
+ };
156
+ }
157
+ if (args.withPromptCacheKey && args.promptCache?.scopeKey) {
158
+ body.prompt_cache_key = args.promptCache.scopeKey;
159
+ }
160
+ return {
161
+ url: buildResponsesUrl(provider),
162
+ headers: {
163
+ 'Content-Type': 'application/json',
164
+ Authorization: `Bearer ${provider.apiKey}`,
165
+ },
166
+ body: JSON.stringify(body),
167
+ };
168
+ }
169
+
170
+ /** 400 是否在抱怨 reasoning / effort 取值(模型不支持该档位,或根本不是推理模型)。 */
171
+ export function isReasoningEffortRejected(status: number, errText: string): boolean {
172
+ if (status !== 400 && status !== 422) return false;
173
+ const text = errText.toLowerCase();
174
+ if (!text.includes('reasoning')) return false;
175
+ return (
176
+ text.includes('not support') ||
177
+ text.includes('unsupported') ||
178
+ text.includes('invalid') ||
179
+ text.includes('not valid') ||
180
+ text.includes('unknown') ||
181
+ text.includes('unrecognized') ||
182
+ text.includes('unexpected') ||
183
+ text.includes('not allowed') ||
184
+ text.includes('supported values')
185
+ );
186
+ }
187
+
188
+ function finiteNonNegative(value: unknown): number | undefined {
189
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
190
+ }
191
+
192
+ /** 每个请求一个新实例;把 Responses 的 SSE `data:` 载荷翻成 LlmStreamEvent。 */
193
+ export function createResponsesSSEParser(): {
194
+ handleData(dataStr: string): LlmStreamEvent[];
195
+ buildFallbackDone(): LlmStreamEvent;
196
+ } {
197
+ /** output_index → 本轮第几个工具调用(loop 按 index 合并分片)。 */
198
+ const toolIndex = new Map<number, number>();
199
+ const streamedArguments = new Set<number>();
200
+ let stopReason: 'stop' | 'tool_calls' | 'length' | 'unknown' = 'unknown';
201
+ let sawToolCall = false;
202
+ let promptTokens: number | undefined;
203
+ let outputTokens: number | undefined;
204
+ let cachedTokens: number | undefined;
205
+ let promptCacheUsage: PromptCacheUsage = { reported: false };
206
+
207
+ const readUsage = (usage: unknown) => {
208
+ if (!usage || typeof usage !== 'object') return;
209
+ const u = usage as Record<string, unknown>;
210
+ // input_tokens 已含命中缓存的部分(与 Chat Completions 的 prompt_tokens 同语义)。
211
+ promptTokens = finiteNonNegative(u.input_tokens) ?? promptTokens;
212
+ outputTokens = finiteNonNegative(u.output_tokens) ?? outputTokens;
213
+ const details = u.input_tokens_details;
214
+ if (details && typeof details === 'object') {
215
+ cachedTokens = finiteNonNegative((details as Record<string, unknown>).cached_tokens) ?? cachedTokens;
216
+ }
217
+ promptCacheUsage = {
218
+ reported: cachedTokens !== undefined,
219
+ ...(promptTokens !== undefined ? { promptTokens } : {}),
220
+ ...(cachedTokens !== undefined ? { cacheReadTokens: cachedTokens } : {}),
221
+ };
222
+ };
223
+ const done = (): LlmStreamEvent => ({
224
+ type: 'done',
225
+ stopReason,
226
+ promptTokens,
227
+ outputTokens,
228
+ cachedPromptTokens: cachedTokens,
229
+ promptCacheUsage,
230
+ });
231
+ const indexFor = (outputIndex: number) => {
232
+ let index = toolIndex.get(outputIndex);
233
+ if (index === undefined) {
234
+ index = toolIndex.size;
235
+ toolIndex.set(outputIndex, index);
236
+ }
237
+ return index;
238
+ };
239
+
240
+ return {
241
+ buildFallbackDone: done,
242
+ handleData(dataStr: string): LlmStreamEvent[] {
243
+ if (dataStr === '[DONE]') return [done()];
244
+ let data: any;
245
+ try {
246
+ data = JSON.parse(dataStr);
247
+ } catch {
248
+ return [];
249
+ }
250
+ switch (data.type) {
251
+ case 'response.output_text.delta':
252
+ return typeof data.delta === 'string' && data.delta
253
+ ? [{ type: 'text_delta', delta: data.delta }]
254
+ : [];
255
+ case 'response.reasoning_summary_text.delta':
256
+ case 'response.reasoning_text.delta':
257
+ return typeof data.delta === 'string' && data.delta
258
+ ? [{ type: 'reasoning_delta', field: 'reasoning', delta: data.delta }]
259
+ : [];
260
+ case 'response.output_item.added': {
261
+ const item = data.item;
262
+ if (item?.type !== 'function_call') return [];
263
+ sawToolCall = true;
264
+ // id 用 call_id(回传 function_call_output 时必须对得上),不是 fc_ 的 item id。
265
+ return [{ type: 'tool_call_delta', index: indexFor(data.output_index ?? 0), id: item.call_id, name: item.name }];
266
+ }
267
+ case 'response.function_call_arguments.delta': {
268
+ const outputIndex = data.output_index ?? 0;
269
+ if (typeof data.delta !== 'string' || !data.delta) return [];
270
+ streamedArguments.add(outputIndex);
271
+ return [{ type: 'tool_call_delta', index: indexFor(outputIndex), argumentsDelta: data.delta }];
272
+ }
273
+ case 'response.output_item.done': {
274
+ // 兜底:有的网关不发 arguments.delta,只在条目完成时给完整 arguments。
275
+ const item = data.item;
276
+ const outputIndex = data.output_index ?? 0;
277
+ if (item?.type !== 'function_call' || streamedArguments.has(outputIndex)) return [];
278
+ sawToolCall = true;
279
+ const arguments_ = typeof item.arguments === 'string' ? item.arguments : '';
280
+ return arguments_
281
+ ? [{ type: 'tool_call_delta', index: indexFor(outputIndex), id: item.call_id, name: item.name, argumentsDelta: arguments_ }]
282
+ : [];
283
+ }
284
+ case 'response.completed':
285
+ readUsage(data.response?.usage);
286
+ stopReason = sawToolCall ? 'tool_calls' : 'stop';
287
+ return [done()];
288
+ case 'response.incomplete': {
289
+ readUsage(data.response?.usage);
290
+ const reason = data.response?.incomplete_details?.reason;
291
+ stopReason = reason === 'max_output_tokens' ? 'length' : sawToolCall ? 'tool_calls' : 'unknown';
292
+ return [done()];
293
+ }
294
+ case 'response.failed': {
295
+ const error = data.response?.error;
296
+ throw new Error(`OpenAI Responses 流式响应失败:${JSON.stringify(error ?? data.response ?? data)}`);
297
+ }
298
+ case 'error':
299
+ throw new Error(`OpenAI Responses 流式响应报错:${JSON.stringify(data.error ?? data)}`);
300
+ default:
301
+ // response.created / in_progress / content_part.* / output_text.done 等:已知安全的 no-op。
302
+ return [];
303
+ }
304
+ },
305
+ };
306
+ }
@@ -11,11 +11,11 @@ decisions stay visible in one place: retry, consult, execute, publish or interru
11
11
  | `toolExecutor.ts` | Single-call authorization, rules, leases, execution and delivery receipt |
12
12
  | `toolBatch.ts` | Safe concurrency segments, question/advisor barriers and ordered commits |
13
13
  | `progressStream.ts` | Callback-to-generator progress bridge and worker settlement |
14
- | `publication.ts` | Final expression stream and fallback before first visible text |
15
14
  | `messages.ts` | Model message projection, interrupts, screenshot lifecycle, tool deltas |
16
15
  | `toolPresentation.ts` | Human-readable tool descriptions; no execution policy |
17
16
  | `toolSemantics.ts` | Advisor side-effect classification |
18
17
  | `deliveryIntent.ts` | Existing formal-delivery intent policy |
18
+ | `liveDiagramGate.ts` | Holds back a possible text-drawn diagram in a live answer until its fence closes |
19
19
 
20
20
  ## Ownership and invariants
21
21
 
@@ -29,8 +29,23 @@ decisions stay visible in one place: retry, consult, execute, publish or interru
29
29
  Writes and interactive/advisor barriers do not become parallel work.
30
30
  - Host execution hooks start only after preflight checks. UI `tool_start` is a
31
31
  separate event: even a denied/deferred request receives a visible result.
32
- - Final candidate text remains private until delivery checks finish. Publication
33
- does not execute tools or replace a prefix already shown to the user.
32
+ - Before each request the orchestrator decides whether the answer can stream
33
+ live (`liveFinal`): only when no delivery check could still hold it back
34
+ (no owed formal delivery or unresolved delegation). The advisor is not such
35
+ a check: it only reminds (`advisor/advisorNudge.ts`) and never holds an answer
36
+ or defers a tool. Live text is never retracted — on
37
+ interruption or error only the text actually shown is committed, and pending
38
+ messages commit it as progress first. The ask correction applies only to
39
+ private drafts; the diagram correction also works live: `liveDiagramGate`
40
+ holds a possible text drawing until its fence closes, and a box stops the
41
+ visible answer right before it — the shown part becomes a progress message and
42
+ the model continues from there. Otherwise the candidate stays private until
43
+ every check passes and is published verbatim; no second tool-free request
44
+ rewrites it. Corrections retire the draft and add a transient reminder (the
45
+ diagram one carries the draft as internal material until the revised answer
46
+ itself arrives, across any skill-loading round); the kernel never
47
+ writes a tool call the model did not make, except the documented host-forced
48
+ Advisor checkpoint.
34
49
  - Cancellation belongs to the host AbortSignal. Progress stream disposal waits for
35
50
  active workers to settle; it does not grant permission to leave tools orphaned.
36
51
  - Existing exports from `loop.ts` remain compatible for TUI, Desktop, subagents and
@@ -0,0 +1,63 @@
1
+ import { DIAGRAM_FENCE_INFO, FENCE_CLOSE, FENCE_OPEN, isBoxDiagram } from '../prompts/visualPresentation.js';
2
+ /**
3
+ * Lets a live answer be corrected for text-drawn diagrams without ever
4
+ * retracting shown text. Text streams as it arrives, except a fenced block
5
+ * that could be a prose drawing (no info string, `text`, `plaintext`): that is
6
+ * held from its opening line until it closes. Not a box → released at once.
7
+ * A box → the visible answer stops right before it; the loop keeps the shown
8
+ * part and asks the model to continue from there with a real diagram. Fences
9
+ * with a language are code and stream straight through.
10
+ */
11
+ export class LiveDiagramGate {
12
+ #scanned = 0;
13
+ #fence;
14
+ #hit;
15
+ #released = false;
16
+ /** Start of the held box, once one was found (and not released). */
17
+ get hit() {
18
+ return this.#released ? undefined : this.#hit;
19
+ }
20
+ /** Give up on correcting (tool round, no turns left): show everything. */
21
+ release() {
22
+ this.#released = true;
23
+ }
24
+ /** How much of `text` may be shown now; `final` once the model has finished. */
25
+ releasable(text, final = false) {
26
+ if (this.#released)
27
+ return text.length;
28
+ if (this.#hit !== undefined)
29
+ return this.#hit;
30
+ for (let newline = text.indexOf('\n', this.#scanned); newline >= 0; newline = text.indexOf('\n', this.#scanned)) {
31
+ const start = this.#scanned;
32
+ const line = text.slice(start, newline).replace(/\r$/, '');
33
+ this.#scanned = newline + 1;
34
+ if (!this.#fence) {
35
+ const open = FENCE_OPEN.exec(line);
36
+ if (open)
37
+ this.#fence = { start, body: this.#scanned, marker: open[1], candidate: DIAGRAM_FENCE_INFO.test(open[2]) };
38
+ continue;
39
+ }
40
+ const close = FENCE_CLOSE.exec(line);
41
+ if (!close || close[1][0] !== this.#fence.marker[0] || close[1].length < this.#fence.marker.length)
42
+ continue;
43
+ if (this.#fence.candidate && isBoxDiagram(text.slice(this.#fence.body, start))) {
44
+ this.#hit = this.#fence.start;
45
+ return this.#hit;
46
+ }
47
+ this.#fence = undefined;
48
+ }
49
+ if (final) {
50
+ // The model ended inside a fence: judge what it wrote, then show the rest.
51
+ if (this.#fence?.candidate && isBoxDiagram(text.slice(this.#fence.body)))
52
+ this.#hit = this.#fence.start;
53
+ return this.#hit ?? text.length;
54
+ }
55
+ if (this.#fence?.candidate)
56
+ return this.#fence.start;
57
+ // A partial line outside any fence may still turn into a fence opener
58
+ // (list items indent fences by up to 3 spaces).
59
+ if (!this.#fence && /^ {0,3}[`~]/.test(text.slice(this.#scanned)))
60
+ return this.#scanned;
61
+ return text.length;
62
+ }
63
+ }
@@ -0,0 +1,61 @@
1
+ import { DIAGRAM_FENCE_INFO, FENCE_CLOSE, FENCE_OPEN, isBoxDiagram } from '../prompts/visualPresentation.js';
2
+
3
+ /**
4
+ * Lets a live answer be corrected for text-drawn diagrams without ever
5
+ * retracting shown text. Text streams as it arrives, except a fenced block
6
+ * that could be a prose drawing (no info string, `text`, `plaintext`): that is
7
+ * held from its opening line until it closes. Not a box → released at once.
8
+ * A box → the visible answer stops right before it; the loop keeps the shown
9
+ * part and asks the model to continue from there with a real diagram. Fences
10
+ * with a language are code and stream straight through.
11
+ */
12
+ export class LiveDiagramGate {
13
+ #scanned = 0;
14
+ #fence?: { start: number; body: number; marker: string; candidate: boolean };
15
+ #hit?: number;
16
+ #released = false;
17
+
18
+ /** Start of the held box, once one was found (and not released). */
19
+ get hit(): number | undefined {
20
+ return this.#released ? undefined : this.#hit;
21
+ }
22
+
23
+ /** Give up on correcting (tool round, no turns left): show everything. */
24
+ release(): void {
25
+ this.#released = true;
26
+ }
27
+
28
+ /** How much of `text` may be shown now; `final` once the model has finished. */
29
+ releasable(text: string, final = false): number {
30
+ if (this.#released) return text.length;
31
+ if (this.#hit !== undefined) return this.#hit;
32
+ for (let newline = text.indexOf('\n', this.#scanned); newline >= 0; newline = text.indexOf('\n', this.#scanned)) {
33
+ const start = this.#scanned;
34
+ const line = text.slice(start, newline).replace(/\r$/, '');
35
+ this.#scanned = newline + 1;
36
+ if (!this.#fence) {
37
+ const open = FENCE_OPEN.exec(line);
38
+ if (open)
39
+ this.#fence = { start, body: this.#scanned, marker: open[1]!, candidate: DIAGRAM_FENCE_INFO.test(open[2]!) };
40
+ continue;
41
+ }
42
+ const close = FENCE_CLOSE.exec(line);
43
+ if (!close || close[1]![0] !== this.#fence.marker[0] || close[1]!.length < this.#fence.marker.length) continue;
44
+ if (this.#fence.candidate && isBoxDiagram(text.slice(this.#fence.body, start))) {
45
+ this.#hit = this.#fence.start;
46
+ return this.#hit;
47
+ }
48
+ this.#fence = undefined;
49
+ }
50
+ if (final) {
51
+ // The model ended inside a fence: judge what it wrote, then show the rest.
52
+ if (this.#fence?.candidate && isBoxDiagram(text.slice(this.#fence.body))) this.#hit = this.#fence.start;
53
+ return this.#hit ?? text.length;
54
+ }
55
+ if (this.#fence?.candidate) return this.#fence.start;
56
+ // A partial line outside any fence may still turn into a fence opener
57
+ // (list items indent fences by up to 3 spaces).
58
+ if (!this.#fence && /^ {0,3}[`~]/.test(text.slice(this.#scanned))) return this.#scanned;
59
+ return text.length;
60
+ }
61
+ }
@@ -108,6 +108,18 @@ export function pushManualInterruptMarker(history) {
108
108
  id: crypto.randomUUID(),
109
109
  });
110
110
  }
111
+ /**
112
+ * 回答已发布但实质成果没经顾问复核(advisor.enforcement='followup'):补一条系统提示,
113
+ * 让模型自己去调 Advisor。回答不撤回,Advisor 调用由模型发出,宿主不替它伪造。
114
+ */
115
+ export function pushAdvisorFollowUpMarker(history) {
116
+ history.push({
117
+ role: 'user',
118
+ content: '(系统提示:你刚才给出的回答包含实质结论或成果,但还没有经过顾问复核。现在调用 Advisor 复核:意见指出问题就更正并说明;没有异议则用一句话说明已复核,不要重复整篇回答。)',
119
+ timestamp: Date.now(),
120
+ id: crypto.randomUUID(),
121
+ });
122
+ }
111
123
  /**
112
124
  * 有新的用户输入在队列中,当前安全停靠
113
125
  */
@@ -122,6 +122,19 @@ export function pushManualInterruptMarker(history: Message[]): void {
122
122
  });
123
123
  }
124
124
 
125
+ /**
126
+ * 回答已发布但实质成果没经顾问复核(advisor.enforcement='followup'):补一条系统提示,
127
+ * 让模型自己去调 Advisor。回答不撤回,Advisor 调用由模型发出,宿主不替它伪造。
128
+ */
129
+ export function pushAdvisorFollowUpMarker(history: Message[]): void {
130
+ history.push({
131
+ role: 'user',
132
+ content: '(系统提示:你刚才给出的回答包含实质结论或成果,但还没有经过顾问复核。现在调用 Advisor 复核:意见指出问题就更正并说明;没有异议则用一句话说明已复核,不要重复整篇回答。)',
133
+ timestamp: Date.now(),
134
+ id: crypto.randomUUID(),
135
+ });
136
+ }
137
+
125
138
  /**
126
139
  * 有新的用户输入在队列中,当前安全停靠
127
140
  */
@@ -35,8 +35,13 @@ export interface RunQueryOptions {
35
35
  allowedTools?: string[];
36
36
  /** 调用方工具边界名称,仅用于准确解释越界调用;缺省表示宿主能力边界。 */
37
37
  toolBoundaryLabel?: string;
38
- /** Disable Advisor checkpoints for an explicitly read-only embedded node. */
39
- advisorCheckpoints?: boolean;
38
+ /** False turns off the Advisor reminders for an explicitly read-only embedded node. */
39
+ advisorReminders?: boolean;
40
+ /**
41
+ * 实质回答发布后仍未经顾问复核时,再发一次请求让模型自己补问(回答不撤回)。
42
+ * 缺省读配置 `advisor.enforcement === 'followup'`;测试和内嵌节点可显式指定。
43
+ */
44
+ advisorFollowUp?: boolean;
40
45
  /** Tools the host/workflow explicitly requires; emergency schema reduction preserves them. */
41
46
  requiredTools?: string[];
42
47
  /**
@@ -28,7 +28,6 @@ export async function* executeToolBatch(input) {
28
28
  return (![
29
29
  'AskUserQuestion',
30
30
  'Advisor',
31
- 'AdvisorDecision',
32
31
  'OptimizeConversation',
33
32
  'AgentReport',
34
33
  'AgentResolve',
@@ -41,7 +41,6 @@ export async function* executeToolBatch(input: {
41
41
  ![
42
42
  'AskUserQuestion',
43
43
  'Advisor',
44
- 'AdvisorDecision',
45
44
  'OptimizeConversation',
46
45
  'AgentReport',
47
46
  'AgentResolve',
@@ -9,12 +9,12 @@ import { resolveToolPath } from '../tools/shared/fileUtils.js';
9
9
  import { advisorSemanticsFor } from './toolSemantics.js';
10
10
  /** One batch's execution boundary. Scheduling and history writes belong to callers. */
11
11
  export function createToolExecutor(input) {
12
- const { options, runtimeContext, toolCallContext, currentEffectiveToolSet, advisorCoordinator, stallGuard, modelEnvironmentCwd, observedRuleScopes, pendingRuleScopes, currentUserMessageId, executeToolFn, } = input;
12
+ const { options, runtimeContext, toolCallContext, currentEffectiveToolSet, advisorNudge, stallGuard, modelEnvironmentCwd, observedRuleScopes, pendingRuleScopes, currentUserMessageId, executeToolFn, } = input;
13
13
  const { signal, sessionState } = options;
14
14
  // Convert tool execution failures/aborts into results; host hook failures are
15
15
  // not a successful tool outcome and may propagate to the batch boundary.
16
16
  const failureReviews = [];
17
- const runOneImpl = async (tc, reportProgress, advisorConsultation) => {
17
+ const runOneImpl = async (tc, reportProgress) => {
18
18
  let started = false;
19
19
  try {
20
20
  const handoff = options.agentHandoff?.current();
@@ -33,8 +33,9 @@ export function createToolExecutor(input) {
33
33
  result: { ok: false, disposition: 'deferred', error: 'aborted' },
34
34
  };
35
35
  }
36
- const advisorAvailable = currentEffectiveToolSet.names.has('Advisor');
37
- if (!advisorAvailable && stallGuard.shouldReject(tc)) {
36
+ // Rejecting a third identical failing call is the guard's own job, advisor or
37
+ // not: the advisor is only a reminder now, so nothing else stops the loop.
38
+ if (stallGuard.shouldReject(tc)) {
38
39
  return {
39
40
  tc,
40
41
  result: {
@@ -68,15 +69,6 @@ export function createToolExecutor(input) {
68
69
  },
69
70
  };
70
71
  }
71
- const checkpoint = options.advisorCheckpoints === false
72
- ? { allow: true }
73
- : advisorCoordinator.beforeTool(tc, {
74
- ...advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }),
75
- retriesFailure: stallGuard.shouldReject(tc),
76
- });
77
- if (!checkpoint.allow) {
78
- return { tc, result: checkpoint.deferred };
79
- }
80
72
  // 工具执行闸门:宿主注入后覆盖所有工具,包括只读工具。deny → 把 reason 当
81
73
  // tool_result 回填,模型据此改道/如实报告,不抛异常不中断整轮。宿主异常一律 deny。
82
74
  // Keep the host gate's side-effect classification identical to the
@@ -210,7 +202,6 @@ export function createToolExecutor(input) {
210
202
  options.onToolStart?.(tc.function.name, tc.id);
211
203
  result = await executeToolFn(tc.function.name, tc.function.arguments, signal, {
212
204
  ...toolCallContext,
213
- advisorConsultation,
214
205
  delegationTools: currentEffectiveToolSet.tools,
215
206
  cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
216
207
  toolCallId: tc.id,
@@ -244,10 +235,10 @@ export function createToolExecutor(input) {
244
235
  };
245
236
  }
246
237
  }
247
- if (tc.function.name === 'Advisor' || tc.function.name === 'AdvisorDecision') {
248
- // 串行批次内下一项可能就是 AdvisorDecision/受保护写入,必须立即推进协调器状态。
249
- advisorCoordinator.observeToolResult(tc, result);
250
- }
238
+ // A successful consultation lets the model try the failing path again: the
239
+ // guard stops counting those failures against it.
240
+ if (tc.function.name === 'Advisor' && result.ok)
241
+ stallGuard.noteConsultation();
251
242
  started = false;
252
243
  options.onToolEnd?.(tc.id, false);
253
244
  return { tc, result };
@@ -266,37 +257,11 @@ export function createToolExecutor(input) {
266
257
  }
267
258
  };
268
259
  const runOne = async (tc, reportProgress) => {
269
- // Also settle denied/invalid/unavailable calls that never reached the
270
- // provider adapter. This records a real attempt, not an ignored reminder.
271
- let consultation;
272
- if (tc.function.name === 'Advisor') {
273
- let reason;
274
- try {
275
- reason = JSON.parse(tc.function.arguments).reason;
276
- }
277
- catch {
278
- /* validation handles malformed input */
279
- }
280
- consultation = advisorCoordinator.beginConsultation(reason);
281
- }
282
- const outcome = await runOneImpl(tc, reportProgress, consultation);
283
- if (consultation &&
284
- outcome.result.metadata?.kind !== 'advisor_consultation' &&
285
- !(!outcome.result.ok && outcome.result.error === 'aborted')) {
286
- outcome.result = {
287
- ...outcome.result,
288
- metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok),
289
- };
290
- }
291
- const metadata = outcome.result.metadata;
292
- if ((metadata?.kind === 'advisor_decision' && outcome.result.ok) || metadata?.kind === 'advisor_consultation') {
293
- if (metadata.checkpoint === 'stalled')
294
- stallGuard.completeReview(metadata.checkpointKey.split(':').at(-1));
295
- }
260
+ const outcome = await runOneImpl(tc, reportProgress);
296
261
  const failure = stallGuard.observeAttempt(tc, outcome.result);
297
262
  if (failure.stalled && failure.signature) {
298
263
  failureReviews.push(failure);
299
- advisorCoordinator.requireFailureReview(failure.signature, failure.attempts ?? []);
264
+ advisorNudge.notifyStalled(failure.signature);
300
265
  }
301
266
  return outcome;
302
267
  };